From 6df9442cd0654462e5ee5fbd604198ab5013fa89 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Thu, 27 Aug 2026 21:37:34 +0100 Subject: [PATCH 01/15] fix(admin): support typed question responses Render database-backed text, audio, photo, and choice responses across admin activity and user detail views. Add support for the both_answered match state while retaining legacy swipe behavior. --- .../src/components/content/ResponseAnswer.tsx | 32 +++++++++ apps/admin/src/lib/questionResponses.test.ts | 27 ++++++++ apps/admin/src/lib/questionResponses.ts | 67 +++++++++++++++++++ .../src/pages/activity/UserActivityPage.tsx | 44 ++++++------ apps/admin/src/pages/users/MatchChatPage.tsx | 4 +- apps/admin/src/pages/users/UserDetailPage.tsx | 43 +++++++----- 6 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 apps/admin/src/components/content/ResponseAnswer.tsx create mode 100644 apps/admin/src/lib/questionResponses.test.ts create mode 100644 apps/admin/src/lib/questionResponses.ts diff --git a/apps/admin/src/components/content/ResponseAnswer.tsx b/apps/admin/src/components/content/ResponseAnswer.tsx new file mode 100644 index 0000000..7ceb005 --- /dev/null +++ b/apps/admin/src/components/content/ResponseAnswer.tsx @@ -0,0 +1,32 @@ +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { Badge } from '@/components/ui/badge'; +import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; + +interface ResponseAnswerProps { + answer: AnswerType; + questionType?: QuestionType | null; + responseData?: AdminResponseData; +} + +export function ResponseAnswer({ answer, questionType, responseData = null }: ResponseAnswerProps) { + const formatted = formatAdminResponse(questionType, answer, responseData); + const resolvedType = questionType ?? 'swipe'; + + return ( +
+
+ {formatted.label} + {resolvedType !== 'swipe' && ( + + {QUESTION_TYPE_LABELS[resolvedType]} + + )} +
+ {formatted.detail && ( +

+ {formatted.detail} +

+ )} +
+ ); +} diff --git a/apps/admin/src/lib/questionResponses.test.ts b/apps/admin/src/lib/questionResponses.test.ts new file mode 100644 index 0000000..65007b9 --- /dev/null +++ b/apps/admin/src/lib/questionResponses.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { formatAdminResponse, MATCH_TYPE_LABELS, resolveQuestionType } from './questionResponses'; + +describe('admin question response formatting', () => { + it('keeps legacy questions compatible with swipe answers', () => { + expect(resolveQuestionType(null)).toBe('swipe'); + expect(formatAdminResponse(null, 'maybe', null)).toEqual({ label: 'Maybe', detail: null }); + }); + + it('shows typed response details without exposing media storage paths', () => { + expect(formatAdminResponse('text_answer', 'yes', { type: 'text_answer', text: ' My answer ' })) + .toEqual({ label: 'Text submitted', detail: 'My answer' }); + expect(formatAdminResponse('audio', 'yes', { + type: 'audio', + media_path: 'private/user/audio.m4a', + duration_seconds: 12.6, + })).toEqual({ label: 'Audio submitted', detail: '13s recording' }); + expect(formatAdminResponse('photo', 'yes', { + type: 'photo', + media_path: 'private/user/photo.jpg', + })).toEqual({ label: 'Photo submitted', detail: null }); + }); + + it('labels non-swipe matches', () => { + expect(MATCH_TYPE_LABELS.both_answered).toBe('Both Answered'); + }); +}); diff --git a/apps/admin/src/lib/questionResponses.ts b/apps/admin/src/lib/questionResponses.ts new file mode 100644 index 0000000..915fdb8 --- /dev/null +++ b/apps/admin/src/lib/questionResponses.ts @@ -0,0 +1,67 @@ +import type { AnswerType, QuestionType } from '@sauci/shared'; + +export type AdminResponseData = + | { type: 'text_answer'; text: string } + | { type: 'audio'; media_path: string; duration_seconds: number } + | { type: 'photo'; media_path: string } + | { type: 'who_likely'; chosen_user_id: string } + | Record + | null; + +export const QUESTION_TYPE_LABELS: Record = { + swipe: 'Swipe', + text_answer: 'Text answer', + audio: 'Audio', + photo: 'Photo', + who_likely: 'Who is more likely', +}; + +export const MATCH_TYPE_LABELS = { + yes_yes: 'Both Yes!', + yes_maybe: 'Yes + Maybe', + maybe_maybe: 'Both Maybe', + both_answered: 'Both Answered', +} as const; + +export function resolveQuestionType(questionType?: QuestionType | null): QuestionType { + return questionType ?? 'swipe'; +} + +export function formatAdminResponse( + questionType: QuestionType | null | undefined, + answer: AnswerType, + responseData: AdminResponseData, +): { label: string; detail: string | null } { + const resolvedType = resolveQuestionType(questionType); + + if (resolvedType === 'swipe') { + return { + label: answer.charAt(0).toUpperCase() + answer.slice(1), + detail: null, + }; + } + + if (resolvedType === 'text_answer') { + const text = responseData && 'text' in responseData && typeof responseData.text === 'string' + ? responseData.text.trim() + : ''; + return { label: 'Text submitted', detail: text || null }; + } + + if (resolvedType === 'audio') { + const duration = responseData && 'duration_seconds' in responseData + && typeof responseData.duration_seconds === 'number' + ? Math.max(0, Math.round(responseData.duration_seconds)) + : null; + return { + label: 'Audio submitted', + detail: duration === null ? null : `${duration}s recording`, + }; + } + + if (resolvedType === 'photo') { + return { label: 'Photo submitted', detail: null }; + } + + return { label: 'Choice submitted', detail: null }; +} diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx index eb6027d..dce6ce2 100644 --- a/apps/admin/src/pages/activity/UserActivityPage.tsx +++ b/apps/admin/src/pages/activity/UserActivityPage.tsx @@ -23,11 +23,12 @@ import { } from '@/components/ui/tabs'; import { formatDistanceToNow, format } from 'date-fns'; import { toast } from 'sonner'; +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { ResponseAnswer } from '@/components/content/ResponseAnswer'; +import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; import { Activity, ThumbsUp, - ThumbsDown, - Minus, Heart, MessageCircle, UserPlus, @@ -38,7 +39,8 @@ import { interface ResponseActivity { id: string; user_id: string; - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string; profile: { id: string; @@ -48,6 +50,7 @@ interface ResponseActivity { question: { id: string; text: string; + question_type: QuestionType; pack: { id: string; name: string; @@ -58,7 +61,7 @@ interface ResponseActivity { interface MatchActivity { id: string; couple_id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; is_new: boolean; created_at: string; question: { @@ -104,17 +107,11 @@ interface SignupActivity { type ActivityTab = 'responses' | 'matches' | 'messages' | 'signups'; -// Answer icons -const answerConfig = { - yes: { icon: , color: 'text-green-500', bg: 'bg-green-500/10' }, - no: { icon: , color: 'text-red-500', bg: 'bg-red-500/10' }, - maybe: { icon: , color: 'text-yellow-500', bg: 'bg-yellow-500/10' }, -}; - const matchTypeLabels = { yes_yes: { label: 'Both Yes!', variant: 'default' as const }, yes_maybe: { label: 'Yes + Maybe', variant: 'secondary' as const }, maybe_maybe: { label: 'Both Maybe', variant: 'outline' as const }, + both_answered: { label: MATCH_TYPE_LABELS.both_answered, variant: 'secondary' as const }, }; export function UserActivityPage() { @@ -152,10 +149,10 @@ export function UserActivityPage() { const { data, error, count } = await supabase .from('responses') .select(` - id, user_id, answer, created_at, + id, user_id, answer, response_data, created_at, profile:profiles!responses_user_id_fkey(id, name, email), question:questions( - id, text, + id, text, question_type, pack:question_packs(id, name) ) `, { count: 'exact' }) @@ -171,11 +168,13 @@ export function UserActivityPage() { id: item.id, user_id: item.user_id, answer: item.answer, + response_data: item.response_data, created_at: item.created_at, profile: item.profile, question: item.question ? { id: item.question.id, text: item.question.text, + question_type: item.question.question_type ?? 'swipe', pack: item.question.pack, } : null, })); @@ -426,17 +425,6 @@ export function UserActivityPage() { ); }; - // Render answer badge - const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => { - const config = answerConfig[answer]; - return ( - - {config.icon} - {answer} - - ); - }; - return (
{/* Header */} @@ -533,7 +521,13 @@ export function UserActivityPage() { )} - {renderAnswerBadge(item.answer)} + + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} diff --git a/apps/admin/src/pages/users/MatchChatPage.tsx b/apps/admin/src/pages/users/MatchChatPage.tsx index 15d419d..1b53b4d 100644 --- a/apps/admin/src/pages/users/MatchChatPage.tsx +++ b/apps/admin/src/pages/users/MatchChatPage.tsx @@ -13,6 +13,7 @@ import { cn } from '@/lib/utils'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { PaginationControls } from '@/components/ui/pagination'; +import { MATCH_TYPE_LABELS } from '@/lib/questionResponses'; interface Profile { id: string; @@ -22,7 +23,7 @@ interface Profile { interface Match { id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; question: { id: string; text: string; @@ -48,6 +49,7 @@ const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', maybe_maybe: 'Both Maybe', + both_answered: MATCH_TYPE_LABELS.both_answered, }; function formatMessageTime(date: Date): string { diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index bf606b0..1437c71 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -37,9 +37,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { ResponseAnswer } from '@/components/content/ResponseAnswer'; +import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; @@ -82,11 +85,13 @@ interface Partner { interface Response { id: string; - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string | null; question: { id: string; text: string; + question_type: QuestionType; pack: { id: string; name: string; @@ -96,7 +101,7 @@ interface Response { interface Match { id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; is_new: boolean; created_at: string | null; question: { @@ -134,12 +139,14 @@ interface OutstandingQuestion { id: string; text: string; intensity: number | null; + question_type: QuestionType; pack: { id: string; name: string; }; partner_response: { - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string | null; }; } @@ -155,16 +162,11 @@ interface CoupleStreak { updated_at: string; } -const answerIcons = { - yes: , - no: , - maybe: , -}; - const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', maybe_maybe: 'Both Maybe', + both_answered: MATCH_TYPE_LABELS.both_answered, }; @@ -536,7 +538,7 @@ export function UserDetailPage() { const { data: responseData, count: responsesCount, error: responsesError } = await supabase .from('responses') - .select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' }) + .select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' }) .eq('user_id', userId) .order('created_at', { ascending: false }) .range(responsesFrom, responsesTo); @@ -646,6 +648,7 @@ export function UserDetailPage() { .select(` question_id, answer, + response_data, created_at `) .eq('user_id', partnerProfile.id) @@ -660,7 +663,7 @@ export function UserDetailPage() { // Fetch question details (no pack filter — admin sees all) const { data: questionDetails } = await supabase .from('questions') - .select('id, text, intensity, pack:question_packs(id, name)') + .select('id, text, intensity, question_type, pack:question_packs(id, name)') .in('id', unansweredQuestionIds); const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q])); @@ -675,8 +678,13 @@ export function UserDetailPage() { id: qId, text: q.text || '', intensity: q.intensity || null, + question_type: q.question_type ?? 'swipe', pack: { id: q.pack?.id || '', name: q.pack?.name || '' }, - partner_response: { answer: pr.answer, created_at: pr.created_at }, + partner_response: { + answer: pr.answer, + response_data: pr.response_data, + created_at: pr.created_at, + }, }); } } @@ -897,7 +905,7 @@ export function UserDetailPage() { Enabled Packs ({enabledPacks.length}) Live Draw - {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} + {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackResponseDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} {canViewMatches && {matches.length === 0 ?

No matches yet

:
{matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats && }
))}
{ setMatchesPage(1); setMatchesPageSize(size); }} />
}
} {canViewMedia && {mediaMessages.length === 0 ?

No media uploaded

:
{mediaMessages.map((message) => (
{message.media_type === 'video' ? ( @@ -964,8 +972,11 @@ export function UserDetailPage() {
- {answerIcons[q.partner_response.answer]} - {q.partner_response.answer} +
From 7545289dd3982e48bc542b226e4013cfd13abcd4 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Thu, 27 Aug 2026 21:49:42 +0100 Subject: [PATCH 02/15] fix(admin): use npm workspace in Docker Align admin production and development images with the repository's committed npm workspace and lockfile. This ensures shared and Expo-linked dependencies are installed before the admin build runs. --- apps/admin/Dockerfile | 15 ++++----------- apps/admin/Dockerfile.dev | 27 ++++----------------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index 02ce1fa..5198254 100755 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -2,19 +2,12 @@ FROM node:20-bookworm-slim AS builder WORKDIR /app -# Enable pnpm via corepack (allow non-matching packageManager) -ENV COREPACK_ENABLE_STRICT=0 -ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy everything first (workspaces need full structure) +# Copy the npm workspace and install from its committed lockfile. COPY . . - -# Install all dependencies -RUN pnpm install --no-frozen-lockfile +RUN npm ci # Build shared package first -RUN pnpm exec turbo run build --filter=@sauci/shared +RUN npm run build -w @sauci/shared # Build admin app with env vars ARG VITE_SUPABASE_URL @@ -41,7 +34,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL -RUN pnpm exec turbo run build --filter=@sauci/admin +RUN npm run build -w @sauci/admin FROM nginx:alpine COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev index 82e6588..948046d 100644 --- a/apps/admin/Dockerfile.dev +++ b/apps/admin/Dockerfile.dev @@ -2,34 +2,15 @@ FROM node:20-bookworm-slim WORKDIR /app -# Enable pnpm via corepack (allow non-matching packageManager) -ENV COREPACK_ENABLE_STRICT=0 -ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy root package files and workspace configs -COPY package*.json ./ -COPY pnpm-workspace.yaml ./ -COPY turbo.json ./ - -# Copy all workspace package.json files -COPY apps/admin/package.json ./apps/admin/ -COPY apps/mobile/package.json ./apps/mobile/ -COPY apps/supabase/package.json ./apps/supabase/ -COPY apps/web/package.json ./apps/web/ -COPY packages/shared/package.json ./packages/shared/ - -# Install all dependencies at root level (workspaces) -RUN pnpm install --no-frozen-lockfile - -# Copy all source code +# Install the complete npm workspace from its committed lockfile. COPY . . +RUN npm ci # Build shared package -RUN pnpm exec turbo run build --filter=@sauci/shared +RUN npm run build -w @sauci/shared WORKDIR /app/apps/admin EXPOSE 3001 -CMD ["pnpm", "run", "dev"] +CMD ["npm", "run", "dev"] From 650c81856f3985937cb6776f52af28ec23f9ef9d Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Thu, 27 Aug 2026 22:02:06 +0100 Subject: [PATCH 03/15] 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 ( +