Merge remote-tracking branch 'origin/master' into task/self-hosted-backend

# Conflicts:
#	apps/mobile/app/(app)/onboarding.tsx
#	apps/mobile/src/__tests__/stores/packsStore.test.ts
#	apps/mobile/src/features/profile/hooks/useProfileSettings.ts
#	apps/mobile/src/store/packsStore.ts
This commit is contained in:
jjbrunton committed 2026-08-28 14:44:51 +01:00
commit be20391788
77 files changed
+48548 -671

No files matched your search

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import type { ContentReviewStatus } from '@sauci/shared';
import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/contexts/AuthContext';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
CONTENT_STATUS_LABELS,
type ReviewableContentTable,
updateContentReviewStatus,
} from '@/services/contentReviews';
interface ContentReviewControlProps {
table: ReviewableContentTable;
entityId: string;
entityLabel: string;
status?: ContentReviewStatus | null;
reason?: string | null;
onChanged: () => void | Promise<void>;
compact?: boolean;
}
const badgeClasses: Record<ContentReviewStatus, string> = {
unreviewed: 'border-amber-500 text-amber-500',
allowed: 'border-green-500 text-green-500',
archived: 'border-slate-500 text-slate-400',
};
const statusIcons: Record<ContentReviewStatus, typeof ClipboardCheck> = {
unreviewed: ClipboardCheck,
allowed: CheckCircle2,
archived: Archive,
};
export function ContentReviewControl({
table,
entityId,
entityLabel,
status,
reason,
onChanged,
compact = false,
}: ContentReviewControlProps) {
const { isSuperAdmin } = useAuth();
const resolvedStatus = status ?? 'unreviewed';
const StatusIcon = statusIcons[resolvedStatus];
const [open, setOpen] = useState(false);
const [nextStatus, setNextStatus] = useState<ContentReviewStatus>(resolvedStatus);
const [nextReason, setNextReason] = useState(reason ?? '');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setNextStatus(resolvedStatus);
setNextReason(reason ?? '');
}
}, [open, reason, resolvedStatus]);
const save = async () => {
if (!nextReason.trim()) {
toast.error('A review reason is required');
return;
}
setSaving(true);
try {
await updateContentReviewStatus(table, entityId, nextStatus, nextReason);
toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`);
setOpen(false);
await onChanged();
} catch (error) {
toast.error('Failed to update catalogue status');
console.error(error);
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${badgeClasses[resolvedStatus]} ${compact ? 'text-[10px] px-1.5 py-0' : ''}`}
title={reason || 'No review reason recorded'}
>
<StatusIcon className="mr-1 h-3 w-3" />
{CONTENT_STATUS_LABELS[resolvedStatus]}
</Badge>
{isSuperAdmin && (
<>
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
className={compact ? 'h-7 px-2 text-xs' : undefined}
onClick={() => setOpen(true)}
aria-label={`Review catalogue status for ${entityLabel}`}
>
Review
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Review catalogue status</DialogTitle>
<DialogDescription>
This records a reversible editorial decision for {entityLabel}.
Current customer apps do not filter this status yet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor={`content-status-${entityId}`}>Status</Label>
<Select
value={nextStatus}
onValueChange={(value) => {
const selectedStatus = value as ContentReviewStatus;
setNextStatus(selectedStatus);
if (selectedStatus !== resolvedStatus) setNextReason('');
}}
>
<SelectTrigger id={`content-status-${entityId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unreviewed">Needs review</SelectItem>
<SelectItem value="allowed">Allowed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`content-reason-${entityId}`}>Reason</Label>
<Textarea
id={`content-reason-${entityId}`}
value={nextReason}
onChange={(event) => setNextReason(event.target.value)}
placeholder="Explain why this content is allowed, archived, or needs another review"
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={save} disabled={saving || !nextReason.trim()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save decision
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
@@ -0,0 +1,152 @@
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,
responseId,
questionType,
responseData = null,
responderId,
responderName,
}: ResponseAnswerProps) {
const formatted = formatAdminResponse(questionType, answer, responseData, { responderId, responderName });
const resolvedType = questionType ?? 'swipe';
return (
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{formatted.label}</span>
{resolvedType !== 'swipe' && (
<Badge variant="outline" className="text-[10px] font-normal">
{QUESTION_TYPE_LABELS[resolvedType]}
</Badge>
)}
</div>
{formatted.detail && (
<p className="max-w-md whitespace-pre-wrap break-words text-xs text-muted-foreground">
{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`}
/>
);
}