From 650c81856f3985937cb6776f52af28ec23f9ef9d Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Thu, 27 Aug 2026 22:02:06 +0100 Subject: [PATCH] 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. --- .../src/components/content/ResponseAnswer.tsx | 124 ++++++++++++++- apps/admin/src/lib/questionResponses.test.ts | 16 ++ apps/admin/src/lib/questionResponses.ts | 23 +++ .../src/pages/activity/UserActivityPage.tsx | 3 + apps/admin/src/pages/users/UserDetailPage.tsx | 8 +- .../functions/admin-response-media/helpers.ts | 40 +++++ .../functions/admin-response-media/index.ts | 144 ++++++++++++++++++ .../tests/admin-response-media.test.ts | 30 ++++ 8 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 apps/supabase/functions/admin-response-media/helpers.ts create mode 100644 apps/supabase/functions/admin-response-media/index.ts create mode 100644 apps/supabase/tests/admin-response-media.test.ts diff --git a/apps/admin/src/components/content/ResponseAnswer.tsx b/apps/admin/src/components/content/ResponseAnswer.tsx index 7ceb005..6f877ab 100644 --- a/apps/admin/src/components/content/ResponseAnswer.tsx +++ b/apps/admin/src/components/content/ResponseAnswer.tsx @@ -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}

)} + {(resolvedType === 'photo' || resolvedType === 'audio') && responseId && ( + + )} ); } + +function ProtectedResponseMedia({ + responseId, + mediaType, + label, +}: { + responseId: string; + mediaType: 'photo' | 'audio'; + label: string; +}) { + const { session } = useAuth(); + const [url, setUrl] = useState(null); + const [error, setError] = useState(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 ( +

+ {mediaType === 'photo' ? : } + {error} +

+ ); + } + + if (!url) { + return ; + } + + if (mediaType === 'photo') { + return ( + + ); + } + + return ( +