mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
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">
|
||||
{step === 'initial' ? (
|
||||
<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">
|
||||
<ShieldCheck className="h-12 w-12 text-indigo-500" />
|
||||
<div className="bg-indigo-500/20 p-4 rounded-full">
|
||||
<ShieldCheck className="h-12 w-12 text-indigo-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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)}
|
||||
</Badge>
|
||||
<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)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<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)}
|
||||
</Badge>
|
||||
<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)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -113,8 +113,8 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
||||
<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-purple-50 p-4 rounded-full">
|
||||
<Wand2 className="h-12 w-12 text-purple-500" />
|
||||
<div className="bg-purple-500/20 p-4 rounded-full">
|
||||
<Wand2 className="h-12 w-12 text-purple-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
</TableCell>
|
||||
<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 && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -252,8 +252,8 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
<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-6">
|
||||
<div className="bg-amber-50 p-4 rounded-full">
|
||||
<Sparkles className="h-12 w-12 text-amber-500" />
|
||||
<div className="bg-amber-500/20 p-4 rounded-full">
|
||||
<Sparkles className="h-12 w-12 text-amber-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 bg-purple-50 px-4 py-2 rounded-lg">
|
||||
<Wand2 className="h-5 w-5 text-purple-500" />
|
||||
<span className="text-sm font-medium">Text Phrasing</span>
|
||||
<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-400" />
|
||||
<span className="text-sm font-medium text-purple-300">Text Phrasing</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-indigo-50 px-4 py-2 rounded-lg">
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-500" />
|
||||
<span className="text-sm font-medium">Audience Targeting</span>
|
||||
<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-400" />
|
||||
<span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
onClick={() => toggleTextSelection(suggestion.questionId)}
|
||||
@@ -378,11 +378,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<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}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -405,7 +405,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{suggestion.targetSuggestion ? (
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
onClick={() => toggleTargetSelection(suggestion.questionId)}
|
||||
@@ -423,17 +423,17 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{formatTargets(suggestion.question.allowed_couple_genders)}
|
||||
</Badge>
|
||||
<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)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Initiator */}
|
||||
<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)}
|
||||
</Badge>
|
||||
<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)}
|
||||
</Badge>
|
||||
</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
@@ -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.',
|
||||
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.',
|
||||
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];
|
||||
@@ -302,6 +302,14 @@ CRITICAL LANGUAGE RULES:
|
||||
- GOOD: "Send your partner a photo", "Spank your partner", "Give 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):
|
||||
|
||||
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..."
|
||||
|
||||
2. ASYMMETRIC Actions (requires partner_text):
|
||||
- One partner does something TO/FOR the other.
|
||||
- text = what the DOER does: "Give your partner X", "Do Y to your partner"
|
||||
- partner_text = what the RECEIVER gets: "Receive X from your partner", "Have your partner do Y to you"
|
||||
- One partner does something TO/FOR the other, or roles differ.
|
||||
- text = what the INITIATOR does (their action/perspective)
|
||||
- partner_text = what the RECEIVER does/experiences (their action/perspective)
|
||||
- ${asymmetricExamples}
|
||||
- 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:
|
||||
- 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.',
|
||||
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.',
|
||||
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({
|
||||
@@ -541,7 +557,7 @@ export async function polishContent(
|
||||
const openai = getOpenAI();
|
||||
|
||||
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.';
|
||||
|
||||
const contextMap = {
|
||||
@@ -564,6 +580,10 @@ export async function polishContent(
|
||||
- DO NOT change it to something unrelated like "Go to the movies".
|
||||
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
|
||||
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):
|
||||
- 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']
|
||||
- "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):
|
||||
- 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.
|
||||
- Consider WHO the action is TYPICALLY intended for, not just who CAN physically perform it.
|
||||
- In M+F couples, consider the typical heterosexual context of sexual acts.
|
||||
- If text implies a specific gender role, set initiator accordingly.
|
||||
- If text is truly gender-neutral AND role-neutral, leave as null.
|
||||
- ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
|
||||
- SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
|
||||
- If text is gender-neutral, leave as null. When in doubt, use null.
|
||||
|
||||
Key considerations for initiator:
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||
isExplicit: boolean = false
|
||||
@@ -712,7 +805,7 @@ export async function analyzeQuestionText(
|
||||
}));
|
||||
|
||||
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.';
|
||||
|
||||
// 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..."
|
||||
|
||||
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}
|
||||
- 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)
|
||||
|
||||
CRITICAL RULES:
|
||||
@@ -757,6 +858,11 @@ CRITICAL RULES:
|
||||
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.
|
||||
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.
|
||||
Each object should have:
|
||||
@@ -770,7 +876,7 @@ ${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
|
||||
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.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
|
||||
@@ -115,22 +115,22 @@ export function AuditLogsPage() {
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
{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)}...
|
||||
</code>
|
||||
)}
|
||||
{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)}...
|
||||
</code>
|
||||
)}
|
||||
{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(', ')}
|
||||
</code>
|
||||
)}
|
||||
{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
|
||||
</code>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/config';
|
||||
@@ -7,6 +7,8 @@ import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, Trendin
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
|
||||
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -16,75 +18,110 @@ export function DashboardPage() {
|
||||
questions: 0,
|
||||
users: 0,
|
||||
});
|
||||
const [messageStats, setMessageStats] = useState<any[]>([]);
|
||||
const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Fetch counts
|
||||
const [
|
||||
{ count: categoriesCount },
|
||||
{ count: packsCount },
|
||||
{ count: questionsCount },
|
||||
{ count: usersCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
]);
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
// Fetch counts
|
||||
const [
|
||||
{ count: categoriesCount },
|
||||
{ count: packsCount },
|
||||
{ count: questionsCount },
|
||||
{ count: usersCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
categories: categoriesCount || 0,
|
||||
packs: packsCount || 0,
|
||||
questions: questionsCount || 0,
|
||||
users: usersCount || 0,
|
||||
});
|
||||
setStats({
|
||||
categories: categoriesCount || 0,
|
||||
packs: packsCount || 0,
|
||||
questions: questionsCount || 0,
|
||||
users: usersCount || 0,
|
||||
});
|
||||
|
||||
// Fetch message history for chart
|
||||
const endDate = endOfDay(new Date());
|
||||
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
|
||||
// Fetch message history for chart
|
||||
const endDate = endOfDay(new Date());
|
||||
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
|
||||
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString());
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString());
|
||||
|
||||
// Process messages for chart
|
||||
const daysMap = new Map();
|
||||
// Initialize last 7 days with 0
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = subDays(new Date(), 6 - i);
|
||||
const key = format(date, 'MMM d');
|
||||
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);
|
||||
// Process messages for chart
|
||||
const daysMap = new Map();
|
||||
// Initialize last 7 days with 0
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = subDays(new Date(), 6 - i);
|
||||
const key = format(date, 'MMM d');
|
||||
daysMap.set(key, 0);
|
||||
}
|
||||
};
|
||||
|
||||
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 = [
|
||||
{
|
||||
title: 'Categories',
|
||||
@@ -133,9 +170,15 @@ export function DashboardPage() {
|
||||
<div className="relative">
|
||||
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
|
||||
<div className="relative">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight">
|
||||
Dashboard
|
||||
</h1>
|
||||
<RealtimeStatusIndicator
|
||||
status={profilesStatus === 'SUBSCRIBED' && messagesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profilesStatus}
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Welcome back. Here's what's happening with Sauci.
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -92,11 +94,7 @@ export function FeedbackPage() {
|
||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, []);
|
||||
|
||||
const fetchFeedback = async () => {
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -110,13 +108,37 @@ export function FeedbackPage() {
|
||||
|
||||
if (error) throw error;
|
||||
setFeedback(data || []);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
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) => {
|
||||
setSelectedFeedback(item);
|
||||
@@ -196,7 +218,10 @@ export function FeedbackPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -73,10 +75,63 @@ export function AdminsPage() {
|
||||
const [editIsSuperAdmin, setEditIsSuperAdmin] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
fetchAdmins();
|
||||
}, []);
|
||||
}, [fetchAdmins]);
|
||||
|
||||
// Search users for adding
|
||||
useEffect(() => {
|
||||
@@ -113,46 +168,6 @@ export function AdminsPage() {
|
||||
return () => clearTimeout(debounce);
|
||||
}, [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 () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
@@ -269,7 +284,10 @@ export function AdminsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Manage system administrators and their roles
|
||||
</p>
|
||||
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -31,6 +32,11 @@ interface Category {
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -43,6 +49,7 @@ interface QuestionPack {
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
question_count?: number;
|
||||
topics?: Topic[];
|
||||
}
|
||||
|
||||
export function PacksPage() {
|
||||
@@ -54,6 +61,10 @@ export function PacksPage() {
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [editingPack, setEditingPack] = useState<QuestionPack | null>(null);
|
||||
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
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -104,10 +115,34 @@ export function PacksPage() {
|
||||
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(
|
||||
(packData || []).map(p => ({
|
||||
...p,
|
||||
question_count: questionCounts[p.id] || 0,
|
||||
topics: packTopicsMap[p.id] || [],
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -132,6 +167,7 @@ export function PacksPage() {
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
setSelectedTopicIds(new Set());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -145,6 +181,7 @@ export function PacksPage() {
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
});
|
||||
setSelectedTopicIds(new Set(pack.topics?.map(t => t.id) || []));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -156,6 +193,8 @@ export function PacksPage() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let packId: string;
|
||||
|
||||
if (editingPack) {
|
||||
const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
|
||||
name: formData.name,
|
||||
@@ -167,23 +206,47 @@ export function PacksPage() {
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack updated');
|
||||
packId = editingPack.id;
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('question_packs', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
});
|
||||
const { data, error } = await supabase
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
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);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -373,6 +436,67 @@ export function PacksPage() {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -395,7 +519,6 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Generator Dialog */}
|
||||
{/* AI Generator Dialog */}
|
||||
<AiGeneratorDialog
|
||||
open={aiDialogOpen}
|
||||
@@ -408,6 +531,16 @@ export function PacksPage() {
|
||||
onGenerated={handleAiGenerated}
|
||||
/>
|
||||
|
||||
{/* Extract Topics Dialog */}
|
||||
{extractTopicsPack && (
|
||||
<ExtractTopicsDialog
|
||||
open={extractTopicsDialogOpen}
|
||||
onOpenChange={setExtractTopicsDialogOpen}
|
||||
pack={extractTopicsPack}
|
||||
onUpdated={fetchData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Packs Grid */}
|
||||
{packs.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
@@ -442,7 +575,7 @@ export function PacksPage() {
|
||||
</Badge>
|
||||
)}
|
||||
{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" />
|
||||
Published
|
||||
</Badge>
|
||||
@@ -453,12 +586,12 @@ export function PacksPage() {
|
||||
</Badge>
|
||||
)}
|
||||
{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" />
|
||||
Explicit
|
||||
</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" />
|
||||
Safe
|
||||
</Badge>
|
||||
@@ -468,6 +601,17 @@ export function PacksPage() {
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="flex-1">{pack.name}</CardTitle>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -487,6 +631,16 @@ export function PacksPage() {
|
||||
<CardDescription className="line-clamp-2">
|
||||
{pack.description || 'No description'}
|
||||
</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>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -84,7 +86,7 @@ export function QuestionsPage() {
|
||||
target_user_genders: [],
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!packId) return;
|
||||
|
||||
setLoading(true);
|
||||
@@ -113,11 +115,32 @@ export function QuestionsPage() {
|
||||
} finally {
|
||||
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(() => {
|
||||
fetchData();
|
||||
}, [packId]);
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingQuestion(null);
|
||||
@@ -284,14 +307,17 @@ export function QuestionsPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
{pack && (
|
||||
<>
|
||||
<span className="text-3xl">{pack.icon}</span>
|
||||
{pack.name}
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
{pack && (
|
||||
<>
|
||||
<span className="text-3xl">{pack.icon}</span>
|
||||
{pack.name}
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
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 { format, isToday, isYesterday } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
@@ -123,6 +125,22 @@ export function MatchChatPage() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
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(() => {
|
||||
const fetchData = async () => {
|
||||
if (!userId || !matchId) return;
|
||||
@@ -221,6 +239,7 @@ export function MatchChatPage() {
|
||||
<Badge variant="secondary">
|
||||
{matchTypeLabels[match.match_type]}
|
||||
</Badge>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{match.question?.text}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -216,54 +218,53 @@ export function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!userId) return;
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profile
|
||||
const { data: profileData } = await supabase
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profile
|
||||
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')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.select('id, name')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.neq('id', userId)
|
||||
.single();
|
||||
|
||||
setProfile(profileData);
|
||||
setPartner(partnerData);
|
||||
}
|
||||
|
||||
// Fetch partner if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
.from('profiles')
|
||||
.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(`
|
||||
// Fetch responses with question and pack info
|
||||
const { data: responseData } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, answer, created_at,
|
||||
question:questions (
|
||||
id, text,
|
||||
pack:question_packs (name)
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
setResponses((responseData || []) as unknown as Response[]);
|
||||
setResponses((responseData || []) as unknown as Response[]);
|
||||
|
||||
// Fetch matches if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
// Fetch matches if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type, is_new, created_at,
|
||||
question:questions (id, text)
|
||||
`)
|
||||
@@ -271,60 +272,80 @@ export function UserDetailPage() {
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Get message counts for each match
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('match_id')
|
||||
.in('match_id', matchIds);
|
||||
// Get message counts for each match
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('match_id')
|
||||
.in('match_id', matchIds);
|
||||
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => {
|
||||
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
|
||||
});
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => {
|
||||
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
|
||||
});
|
||||
|
||||
setMatches(
|
||||
(matchData || []).map(m => ({
|
||||
...m,
|
||||
message_count: messageCounts[m.id] || 0,
|
||||
})) 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);
|
||||
setMatches(
|
||||
(matchData || []).map(m => ({
|
||||
...m,
|
||||
message_count: messageCounts[m.id] || 0,
|
||||
})) as unknown as Match[]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -363,6 +384,10 @@ export function UserDetailPage() {
|
||||
<h1 className="text-2xl font-bold">
|
||||
{profile.name || 'Unnamed User'}
|
||||
</h1>
|
||||
<RealtimeStatusIndicator
|
||||
status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus}
|
||||
showLabel
|
||||
/>
|
||||
{profile.is_premium && (
|
||||
<Badge className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -43,71 +45,94 @@ export function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles
|
||||
const { data: profilesData, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles
|
||||
const { data: profilesData, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
|
||||
// Create a map of user_id -> storage_bytes
|
||||
const storageMap: Record<string, number> = {};
|
||||
if (storageData) {
|
||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
||||
storageMap[row.owner] = row.total_bytes;
|
||||
// Create a map of user_id -> storage_bytes
|
||||
const storageMap: Record<string, number> = {};
|
||||
if (storageData) {
|
||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
||||
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
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Merge storage data and partner premium status with profiles
|
||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
||||
// Check if partner has premium
|
||||
let partnerIsPremium = false;
|
||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||
partnerIsPremium = partner?.is_premium || false;
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
partner_is_premium: partnerIsPremium,
|
||||
};
|
||||
});
|
||||
|
||||
// Merge storage data and partner premium status with profiles
|
||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
||||
// Check if partner has premium
|
||||
let partnerIsPremium = false;
|
||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||
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();
|
||||
setProfiles(profilesWithExtras);
|
||||
setFilteredProfiles(profilesWithExtras);
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
if (!search.trim()) {
|
||||
setFilteredProfiles(profiles);
|
||||
@@ -142,7 +167,10 @@ export function UsersPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { useFocusEffect } from "@react-navigation/native";
|
||||
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 { colors, gradients, spacing, radius, typography } from "../../../src/theme";
|
||||
import { Events } from "../../../src/lib/analytics";
|
||||
import { getCachedSignedUrl, getStoragePath } from "../../../src/lib/imageCache";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -356,6 +358,8 @@ export default function ChatScreen() {
|
||||
<Image
|
||||
source={{ uri: partner.avatar_url }}
|
||||
style={styles.headerAvatar}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
@@ -492,7 +496,8 @@ export default function ChatScreen() {
|
||||
<Image
|
||||
source={{ uri: fullScreenImage }}
|
||||
style={styles.fullScreenImage}
|
||||
resizeMode="contain"
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
onLoadStart={() => setFullScreenImageLoading(true)}
|
||||
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({
|
||||
item,
|
||||
isMe,
|
||||
@@ -530,26 +525,26 @@ function MessageContent({
|
||||
useEffect(() => {
|
||||
if (!item.media_path) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const fetchSignedUrl = async () => {
|
||||
const storagePath = getStoragePath(item.media_path!);
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(storagePath, 3600); // 1 hour expiry
|
||||
const url = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to get signed URL:', error);
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
if (!isMounted) return;
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setSignedUrl(data.signedUrl);
|
||||
if (url) {
|
||||
setSignedUrl(url);
|
||||
} else {
|
||||
setUrlError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSignedUrl();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [item.media_path]);
|
||||
|
||||
if (item.media_path) {
|
||||
@@ -568,6 +563,8 @@ function MessageContent({
|
||||
source={{ uri: signedUrl }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={(!isMe && !isViewed) ? 20 : 0}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : urlError ? (
|
||||
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}>
|
||||
|
||||
@@ -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 { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
@@ -38,6 +39,7 @@ export default function ProfileScreen() {
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
const [isResettingProgress, setIsResettingProgress] = useState(false);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
||||
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 = () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
@@ -437,6 +473,8 @@ export default function ProfileScreen() {
|
||||
<Image
|
||||
source={{ uri: user.avatar_url }}
|
||||
style={styles.avatarImage}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.avatarInner}>
|
||||
@@ -793,10 +831,50 @@ export default function ProfileScreen() {
|
||||
</GlassCard>
|
||||
</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 */}
|
||||
{__DEV__ && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(500).duration(500)}
|
||||
entering={FadeInDown.delay(couple ? 550 : 500).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.debugSectionTitle}>Debug</Text>
|
||||
@@ -883,7 +961,7 @@ export default function ProfileScreen() {
|
||||
{/* Danger Zone - Only show if in a couple */}
|
||||
{couple && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(525).duration(500)}
|
||||
entering={FadeInDown.delay(600).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||
@@ -915,7 +993,7 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Version */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(couple ? 575 : 525).duration(500)}
|
||||
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
|
||||
style={styles.versionContainer}
|
||||
>
|
||||
<View style={styles.versionBadge}>
|
||||
@@ -1349,6 +1427,69 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "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
|
||||
dangerSectionTitle: {
|
||||
...typography.caption1,
|
||||
|
||||
@@ -224,20 +224,30 @@ PODS:
|
||||
- Yoga
|
||||
- ExpoAppleAuthentication (7.1.3):
|
||||
- ExpoModulesCore
|
||||
- ExpoAsset (11.0.5):
|
||||
- ExpoModulesCore
|
||||
- ExpoBlur (14.0.3):
|
||||
- ExpoModulesCore
|
||||
- ExpoClipboard (7.0.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (18.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.10):
|
||||
- ExpoFont (13.0.4):
|
||||
- ExpoModulesCore
|
||||
- ExpoHaptics (14.0.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoHead (4.0.22):
|
||||
- ExpoModulesCore
|
||||
- ExpoImage (2.0.7):
|
||||
- ExpoModulesCore
|
||||
- libavif/libdav1d
|
||||
- SDWebImage (~> 5.19.1)
|
||||
- SDWebImageAVIFCoder (~> 0.11.0)
|
||||
- SDWebImageSVGCoder (~> 1.7.0)
|
||||
- ExpoImagePicker (16.0.6):
|
||||
- ExpoModulesCore
|
||||
- ExpoKeepAwake (14.0.3):
|
||||
- ExpoModulesCore
|
||||
- ExpoLinearGradient (14.0.2):
|
||||
- ExpoModulesCore
|
||||
- ExpoLinking (7.0.5):
|
||||
@@ -278,6 +288,11 @@ PODS:
|
||||
- hermes-engine (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):
|
||||
- RevenueCat (= 5.51.1)
|
||||
- RCT-Folly (2024.10.14.00):
|
||||
@@ -1991,6 +2006,14 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- Sentry/HybridSDK (= 8.48.0)
|
||||
- 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)
|
||||
- SocketRocket (0.7.1)
|
||||
- Yoga (0.0.0)
|
||||
@@ -1998,30 +2021,33 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`)
|
||||
- DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||
- EXApplication (from `../../../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../../../node_modules/expo-constants/ios`)
|
||||
- EXImageLoader (from `../../../node_modules/expo-image-loader/ios`)
|
||||
- EXJSONUtils (from `../../../node_modules/expo-json-utils/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-dev-client (from `../../../node_modules/expo-dev-client/ios`)
|
||||
- expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`)
|
||||
- expo-dev-menu (from `../../../node_modules/expo-dev-menu`)
|
||||
- expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`)
|
||||
- ExpoAppleAuthentication (from `../../../node_modules/expo-apple-authentication/ios`)
|
||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
- ExpoHaptics (from `../node_modules/expo-haptics/ios`)
|
||||
- ExpoHead (from `../node_modules/expo-router/ios`)
|
||||
- ExpoImagePicker (from `../node_modules/expo-image-picker/ios`)
|
||||
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoAsset (from `../../../node_modules/expo-asset/ios`)
|
||||
- ExpoBlur (from `../../../node_modules/expo-blur/ios`)
|
||||
- ExpoClipboard (from `../../../node_modules/expo-clipboard/ios`)
|
||||
- ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../../../node_modules/expo-font/ios`)
|
||||
- ExpoHaptics (from `../../../node_modules/expo-haptics/ios`)
|
||||
- ExpoHead (from `../../../node_modules/expo-router/ios`)
|
||||
- ExpoImage (from `../../../node_modules/expo-image/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`)
|
||||
- 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`)
|
||||
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
|
||||
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
|
||||
@@ -2059,7 +2085,7 @@ DEPENDENCIES:
|
||||
- React-logger (from `../../../node_modules/react-native/ReactCommon/logger`)
|
||||
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
||||
- 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-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
- React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||
@@ -2088,17 +2114,22 @@ DEPENDENCIES:
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
|
||||
- "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`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
- RNReanimated (from `../../../node_modules/react-native-reanimated`)
|
||||
- RNScreens (from `../../../node_modules/react-native-screens`)
|
||||
- "RNSentry (from `../../../node_modules/@sentry/react-native`)"
|
||||
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- libavif
|
||||
- libdav1d
|
||||
- PurchasesHybridCommon
|
||||
- RevenueCat
|
||||
- SDWebImage
|
||||
- SDWebImageAVIFCoder
|
||||
- SDWebImageSVGCoder
|
||||
- Sentry
|
||||
- SocketRocket
|
||||
|
||||
@@ -2108,17 +2139,17 @@ EXTERNAL SOURCES:
|
||||
DoubleConversion:
|
||||
:podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
|
||||
EXApplication:
|
||||
:path: "../node_modules/expo-application/ios"
|
||||
:path: "../../../node_modules/expo-application/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
:path: "../../../node_modules/expo-constants/ios"
|
||||
EXImageLoader:
|
||||
:path: "../node_modules/expo-image-loader/ios"
|
||||
:path: "../../../node_modules/expo-image-loader/ios"
|
||||
EXJSONUtils:
|
||||
:path: "../../../node_modules/expo-json-utils/ios"
|
||||
EXManifests:
|
||||
:path: "../../../node_modules/expo-manifests/ios"
|
||||
EXNotifications:
|
||||
:path: "../node_modules/expo-notifications/ios"
|
||||
:path: "../../../node_modules/expo-notifications/ios"
|
||||
Expo:
|
||||
:path: "../../../node_modules/expo"
|
||||
expo-dev-client:
|
||||
@@ -2131,30 +2162,36 @@ EXTERNAL SOURCES:
|
||||
:path: "../../../node_modules/expo-dev-menu-interface/ios"
|
||||
ExpoAppleAuthentication:
|
||||
:path: "../../../node_modules/expo-apple-authentication/ios"
|
||||
ExpoAsset:
|
||||
:path: "../../../node_modules/expo-asset/ios"
|
||||
ExpoBlur:
|
||||
:path: "../node_modules/expo-blur/ios"
|
||||
:path: "../../../node_modules/expo-blur/ios"
|
||||
ExpoClipboard:
|
||||
:path: "../node_modules/expo-clipboard/ios"
|
||||
:path: "../../../node_modules/expo-clipboard/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/expo-file-system/ios"
|
||||
:path: "../../../node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
:path: "../node_modules/expo-font/ios"
|
||||
:path: "../../../node_modules/expo-font/ios"
|
||||
ExpoHaptics:
|
||||
:path: "../node_modules/expo-haptics/ios"
|
||||
:path: "../../../node_modules/expo-haptics/ios"
|
||||
ExpoHead:
|
||||
:path: "../node_modules/expo-router/ios"
|
||||
:path: "../../../node_modules/expo-router/ios"
|
||||
ExpoImage:
|
||||
:path: "../../../node_modules/expo-image/ios"
|
||||
ExpoImagePicker:
|
||||
:path: "../node_modules/expo-image-picker/ios"
|
||||
:path: "../../../node_modules/expo-image-picker/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../../../node_modules/expo-keep-awake/ios"
|
||||
ExpoLinearGradient:
|
||||
:path: "../node_modules/expo-linear-gradient/ios"
|
||||
:path: "../../../node_modules/expo-linear-gradient/ios"
|
||||
ExpoLinking:
|
||||
:path: "../node_modules/expo-linking/ios"
|
||||
:path: "../../../node_modules/expo-linking/ios"
|
||||
ExpoLocalAuthentication:
|
||||
:path: "../../../node_modules/expo-local-authentication/ios"
|
||||
ExpoModulesCore:
|
||||
:path: "../../../node_modules/expo-modules-core"
|
||||
ExpoSecureStore:
|
||||
:path: "../node_modules/expo-secure-store/ios"
|
||||
:path: "../../../node_modules/expo-secure-store/ios"
|
||||
EXUpdatesInterface:
|
||||
:path: "../../../node_modules/expo-updates-interface/ios"
|
||||
fast_float:
|
||||
@@ -2227,7 +2264,7 @@ EXTERNAL SOURCES:
|
||||
React-microtasksnativemodule:
|
||||
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
:path: "../../../node_modules/react-native-safe-area-context"
|
||||
React-nativeconfig:
|
||||
:path: "../../../node_modules/react-native/ReactCommon"
|
||||
React-NativeModulesApple:
|
||||
@@ -2285,11 +2322,11 @@ EXTERNAL SOURCES:
|
||||
RNCAsyncStorage:
|
||||
:path: "../../../node_modules/@react-native-async-storage/async-storage"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
:path: "../../../node_modules/react-native-gesture-handler"
|
||||
RNPurchases:
|
||||
:path: "../../../node_modules/react-native-purchases"
|
||||
RNReanimated:
|
||||
:path: "../node_modules/react-native-reanimated"
|
||||
:path: "../../../node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
:path: "../../../node_modules/react-native-screens"
|
||||
RNSentry:
|
||||
@@ -2312,13 +2349,16 @@ SPEC CHECKSUMS:
|
||||
expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be
|
||||
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
||||
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
|
||||
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
|
||||
ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742
|
||||
ExpoClipboard: 44fd1c8959ee8f6175d059dc011b154c9709a969
|
||||
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
|
||||
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
|
||||
ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
|
||||
ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d
|
||||
ExpoHead: 2a14db58219c0a506e4a409ea83f884eab23f555
|
||||
ExpoImage: d840b256050f4428d2942bc2b6e9251f9e0d7021
|
||||
ExpoImagePicker: 24e5ba8da111f74519b1e6dc556e0b438b2b8464
|
||||
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
|
||||
ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60
|
||||
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
||||
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
||||
@@ -2330,6 +2370,8 @@ SPEC CHECKSUMS:
|
||||
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
||||
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
||||
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
||||
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
||||
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
|
||||
@@ -2392,9 +2434,12 @@ SPEC CHECKSUMS:
|
||||
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
|
||||
RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633
|
||||
RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058
|
||||
RNReanimated: 76053156ece6bf6e6bdf8a7d03db216285e4719c
|
||||
RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4
|
||||
RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89
|
||||
RNSentry: 41eab31e46a191a4f9296d64a318b2a6bf0f98c5
|
||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
|
||||
|
||||
@@ -175,6 +175,8 @@
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
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-cxxreact/React-cxxreact_privacy.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}/boost/boost_privacy.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-cxxreact_privacy.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}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||
@@ -394,6 +398,9 @@
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -421,6 +428,9 @@
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"expo-file-system": "~18.0.12",
|
||||
"expo-font": "~13.0.4",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.7",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.0",
|
||||
|
||||
@@ -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" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user