mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(admin): preview response media
Show who-likely choices in human terms and provide protected photo and audio previews in activity and user response views. Fetch media by response ID through an authenticated Edge Function that enforces the view_responses permission.
This commit is contained in:
1 parent
7545289dd3
commit
650c81856f
8 files changed
+385
-3
No files matched your search
@@ -1,15 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AnswerType, QuestionType } from '@sauci/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabaseConfig } from '@/config';
|
||||
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
||||
|
||||
interface ResponseAnswerProps {
|
||||
answer: AnswerType;
|
||||
responseId?: string;
|
||||
questionType?: QuestionType | null;
|
||||
responseData?: AdminResponseData;
|
||||
responderId?: string;
|
||||
responderName?: string | null;
|
||||
}
|
||||
|
||||
export function ResponseAnswer({ answer, questionType, responseData = null }: ResponseAnswerProps) {
|
||||
const formatted = formatAdminResponse(questionType, answer, responseData);
|
||||
export function ResponseAnswer({
|
||||
answer,
|
||||
responseId,
|
||||
questionType,
|
||||
responseData = null,
|
||||
responderId,
|
||||
responderName,
|
||||
}: ResponseAnswerProps) {
|
||||
const formatted = formatAdminResponse(questionType, answer, responseData, { responderId, responderName });
|
||||
const resolvedType = questionType ?? 'swipe';
|
||||
|
||||
return (
|
||||
@@ -27,6 +42,111 @@ export function ResponseAnswer({ answer, questionType, responseData = null }: Re
|
||||
{formatted.detail}
|
||||
</p>
|
||||
)}
|
||||
{(resolvedType === 'photo' || resolvedType === 'audio') && responseId && (
|
||||
<ProtectedResponseMedia
|
||||
responseId={responseId}
|
||||
mediaType={resolvedType}
|
||||
label={responderName || 'User response'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedResponseMedia({
|
||||
responseId,
|
||||
mediaType,
|
||||
label,
|
||||
}: {
|
||||
responseId: string;
|
||||
mediaType: 'photo' | 'audio';
|
||||
label: string;
|
||||
}) {
|
||||
const { session } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.access_token) {
|
||||
setError('Sign in to load media');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ responseId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body?.error || `Failed to load media (${response.status})`);
|
||||
}
|
||||
|
||||
objectUrl = URL.createObjectURL(await response.blob());
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load media');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [responseId, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="flex items-center gap-1 text-xs text-destructive" role="alert">
|
||||
{mediaType === 'photo' ? <ImageIcon className="h-3 w-3" /> : <Music2 className="h-3 w-3" />}
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return <Skeleton className={mediaType === 'photo' ? 'h-20 w-28 rounded-md' : 'h-10 w-64 rounded-md'} />;
|
||||
}
|
||||
|
||||
if (mediaType === 'photo') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="block overflow-hidden rounded-md border bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
|
||||
aria-label={`Open ${label} photo response`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${label} photo response`}
|
||||
className="h-20 w-28 object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<audio
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="h-10 w-64 max-w-full"
|
||||
aria-label={`Play ${label} audio response`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,4 +24,20 @@ describe('admin question response formatting', () => {
|
||||
it('labels non-swipe matches', () => {
|
||||
expect(MATCH_TYPE_LABELS.both_answered).toBe('Both Answered');
|
||||
});
|
||||
|
||||
it('shows who was chosen without exposing user identifiers', () => {
|
||||
expect(formatAdminResponse(
|
||||
'who_likely',
|
||||
'yes',
|
||||
{ type: 'who_likely', chosen_user_id: 'user-1' },
|
||||
{ responderId: 'user-1', responderName: 'Alex' },
|
||||
)).toEqual({ label: 'Chose themselves', detail: 'Alex chose themselves' });
|
||||
|
||||
expect(formatAdminResponse(
|
||||
'who_likely',
|
||||
'yes',
|
||||
{ type: 'who_likely', chosen_user_id: 'user-2' },
|
||||
{ responderId: 'user-1', responderName: 'Alex' },
|
||||
)).toEqual({ label: 'Chose their partner', detail: 'Alex chose their partner' });
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export function formatAdminResponse(
|
||||
questionType: QuestionType | null | undefined,
|
||||
answer: AnswerType,
|
||||
responseData: AdminResponseData,
|
||||
context?: { responderId?: string; responderName?: string | null },
|
||||
): { label: string; detail: string | null } {
|
||||
const resolvedType = resolveQuestionType(questionType);
|
||||
|
||||
@@ -63,5 +64,27 @@ export function formatAdminResponse(
|
||||
return { label: 'Photo submitted', detail: null };
|
||||
}
|
||||
|
||||
const chosenUserId = responseData && 'chosen_user_id' in responseData
|
||||
&& typeof responseData.chosen_user_id === 'string'
|
||||
? responseData.chosen_user_id
|
||||
: null;
|
||||
const choseSelf = chosenUserId && context?.responderId
|
||||
? chosenUserId === context.responderId
|
||||
: null;
|
||||
|
||||
if (choseSelf === true) {
|
||||
return {
|
||||
label: 'Chose themselves',
|
||||
detail: context?.responderName ? `${context.responderName} chose themselves` : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (choseSelf === false) {
|
||||
return {
|
||||
label: 'Chose their partner',
|
||||
detail: context?.responderName ? `${context.responderName} chose their partner` : null,
|
||||
};
|
||||
}
|
||||
|
||||
return { label: 'Choice submitted', detail: null };
|
||||
}
|
||||
@@ -524,8 +524,11 @@ export function UserActivityPage() {
|
||||
<TableCell>
|
||||
<ResponseAnswer
|
||||
answer={item.answer}
|
||||
responseId={item.id}
|
||||
questionType={item.question?.question_type}
|
||||
responseData={item.response_data}
|
||||
responderId={item.user_id}
|
||||
responderName={item.profile?.name || item.profile?.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
|
||||
@@ -145,6 +145,7 @@ interface OutstandingQuestion {
|
||||
name: string;
|
||||
};
|
||||
partner_response: {
|
||||
id: string;
|
||||
answer: AnswerType;
|
||||
response_data: AdminResponseData;
|
||||
created_at: string | null;
|
||||
@@ -646,6 +647,7 @@ export function UserDetailPage() {
|
||||
const { data: partnerResponses } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
id,
|
||||
question_id,
|
||||
answer,
|
||||
response_data,
|
||||
@@ -681,6 +683,7 @@ export function UserDetailPage() {
|
||||
question_type: q.question_type ?? 'swipe',
|
||||
pack: { id: q.pack?.id || '', name: q.pack?.name || '' },
|
||||
partner_response: {
|
||||
id: pr.id,
|
||||
answer: pr.answer,
|
||||
response_data: pr.response_data,
|
||||
created_at: pr.created_at,
|
||||
@@ -905,7 +908,7 @@ export function UserDetailPage() {
|
||||
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
|
||||
</TabsList>
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} questionType={r.question.question_type} responseData={r.response_data} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} responseId={r.id} questionType={r.question.question_type} responseData={r.response_data} responderId={userId} responderName={profile.name || profile.email} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
@@ -974,8 +977,11 @@ export function UserDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ResponseAnswer
|
||||
answer={q.partner_response.answer}
|
||||
responseId={q.partner_response.id}
|
||||
questionType={q.question_type}
|
||||
responseData={q.partner_response.response_data}
|
||||
responderId={partner?.id}
|
||||
responderName={partner?.name}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
Reference in new issue
Block a user