Compare commits

..

17 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
68384156ab Fix selective-approval RPC params
Update edge function to call process_approval_transaction with correct parameters:
- remove p_trace_id and p_parent_span_id
- add p_approval_mode: 'selective' and p_idempotency_key: idempotencyKey
This aligns with database function signature and resolves 500 error.

X-Lovable-Edit-ID: edt-6e45b77e-1d54-4173-af1a-dcbcd886645d
2025-11-12 15:12:31 +00:00
gpt-engineer-app[bot]
5cc5d3eab6 testing changes with virtual file cleanup 2025-11-12 15:12:30 +00:00
gpt-engineer-app[bot]
706e36c847 Add staggered expand animation
Implement sequential delays for detailed view expansions:
- Add staggerIndex prop support to DetailedViewCollapsible and apply per-item animation delays.
- Pass item index in SubmissionItemsList when rendering detailed sections.
- Ensure each detailed view expands with a 50ms incremental delay (up to a max) for a staggered effect.

X-Lovable-Edit-ID: edt-6eb47d5c-853d-43ab-96a7-16a5cc006c30
2025-11-12 14:56:11 +00:00
gpt-engineer-app[bot]
a1beba6996 testing changes with virtual file cleanup 2025-11-12 14:56:11 +00:00
gpt-engineer-app[bot]
d7158756ef Animate detailed view transitions
Improve user experience by adding smooth animation transitions to expand/collapse of All Fields (Detailed View) sections, enhance collapsible base to support animation, and apply transitions to detailed view wrapper and chevron indicators.

X-Lovable-Edit-ID: edt-9a567ba5-b52f-46b3-bdef-b847b9ba7963
2025-11-12 14:53:19 +00:00
gpt-engineer-app[bot]
3330a8fac9 testing changes with virtual file cleanup 2025-11-12 14:53:18 +00:00
gpt-engineer-app[bot]
c09a343d08 Add moderation_preferences column
Adds a JSONB moderation_preferences column to user_preferences (with default '{}'), plus comment and GIN index, enabling per-user persistence of detailed view state and resolving TS errors.

X-Lovable-Edit-ID: edt-b953d926-c053-45f2-b434-2b776f3d9569
2025-11-12 14:50:57 +00:00
gpt-engineer-app[bot]
9893567a30 testing changes with virtual file cleanup 2025-11-12 14:50:56 +00:00
gpt-engineer-app[bot]
771405961f Add tooltip for expanded count
Enhance persistence for moderator preferences

- Add tooltip to moderation queue toggle showing number of items with detailed views expanded (based on global state, tooltip adapts to expanded/collapsed).
- Persist expanded/collapsed state per moderator in the database instead of localStorage, integrating with user preferences and Supabase backend.

X-Lovable-Edit-ID: edt-61e75a20-f83d-40b2-8bc4-b6ff40b23450
2025-11-12 14:45:07 +00:00
gpt-engineer-app[bot]
437e2b353c testing changes with virtual file cleanup 2025-11-12 14:45:06 +00:00
gpt-engineer-app[bot]
44a713af62 Add global toggle for detailed views
Implement a new global control in the moderation queue header to expand/collapse all "All Fields (Detailed View)" sections at once. This includes:
- Integrating useDetailedViewState with a new header-level button in QueueFilters
- Adding a button that toggles all detailed views and shows Expand/Collapse state
- Ensuring the toggle updates all DetailedViewCollapsible instances via shared state
- Keeping UI consistent with existing icons and styling

X-Lovable-Edit-ID: edt-22d9eca7-0c70-44d8-865d-791ef884dfbd
2025-11-12 14:42:34 +00:00
gpt-engineer-app[bot]
46275e0f1e testing changes with virtual file cleanup 2025-11-12 14:42:33 +00:00
gpt-engineer-app[bot]
6bd7d24a1b Add item-level history badge and animations
- Show a dynamic field-count badge next to All Fields (Detailed View) in the moderation queue
- Animate collapsible sections with smooth transitions for expand/collapse
- Pass fieldCount to DetailedViewCollapsible and render count alongside header; add animation utility in DetailedViewCollapsible.tsx
- Ensure SubmissionItemsList passes item data to calculate field counts and display badges accordingly

X-Lovable-Edit-ID: edt-ffd226b0-af99-491b-b6b8-3fe0063e0082
2025-11-12 14:40:52 +00:00
gpt-engineer-app[bot]
72e76e86af testing changes with virtual file cleanup 2025-11-12 14:40:51 +00:00
gpt-engineer-app[bot]
a35486fb11 Add collapsible detailed view
Implements expand/collapse for the All Fields (Detailed View) sections in the moderation queue:
- Adds useDetailedViewState hook to persist collapse state in localStorage (default collapsed)
- Adds DetailedViewCollapsible wrapper component using Radix Collapsible
- Updates SubmissionItemsList to wrap each detailed view block with the new collapsible, and imports the new hook and component

X-Lovable-Edit-ID: edt-a95a840d-e7e7-4f9e-aa25-03bb68194aee
2025-11-12 14:36:50 +00:00
gpt-engineer-app[bot]
3d3ae57ee3 testing changes with virtual file cleanup 2025-11-12 14:36:49 +00:00
gpt-engineer-app[bot]
46c08e10e8 Add item-level approval history
Introduce ItemLevelApprovalHistory component to display which specific submission items were approved, when, and by whom, and integrate it into QueueItem between metadata and audit trail. The component shows item names, approval timestamps, action types, and reviewer info.
2025-11-12 14:28:50 +00:00
9 changed files with 474 additions and 40 deletions

View File

@@ -0,0 +1,78 @@
import { ChevronDown } from 'lucide-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface DetailedViewCollapsibleProps {
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
fieldCount?: number;
className?: string;
staggerIndex?: number;
}
/**
* Collapsible wrapper for detailed field-by-field view sections
* Provides expand/collapse functionality with visual indicators
*/
export function DetailedViewCollapsible({
isCollapsed,
onToggle,
children,
fieldCount,
className,
staggerIndex = 0
}: DetailedViewCollapsibleProps) {
// Calculate stagger delay: 50ms per item, max 300ms
const staggerDelay = Math.min(staggerIndex * 50, 300);
return (
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
<div className={cn("mt-6 pt-6 border-t", className)}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
All Fields (Detailed View)
</span>
{fieldCount !== undefined && fieldCount > 0 && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
>
{fieldCount}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground normal-case font-normal">
{isCollapsed ? 'Show' : 'Hide'}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-all duration-300 ease-out",
!isCollapsed && "rotate-180"
)}
/>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className="mt-3"
style={{
animationDelay: `${staggerDelay}ms`,
transitionDelay: `${staggerDelay}ms`
}}
>
{children}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,125 @@
import { memo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, User } from 'lucide-react';
import type { SubmissionItem } from '@/types/moderation';
interface ItemLevelApprovalHistoryProps {
items: SubmissionItem[];
reviewerProfile?: {
user_id: string;
username: string;
display_name?: string | null;
avatar_url?: string | null;
} | null;
}
export const ItemLevelApprovalHistory = memo(({
items,
reviewerProfile,
}: ItemLevelApprovalHistoryProps) => {
// Filter to only approved items with timestamps
const approvedItems = items.filter(
item => item.status === 'approved' && (item as any).approved_at
);
if (approvedItems.length === 0) {
return null;
}
// Sort by approval time (newest first)
const sortedItems = [...approvedItems].sort((a, b) => {
const timeA = new Date((a as any).approved_at).getTime();
const timeB = new Date((b as any).approved_at).getTime();
return timeB - timeA;
});
// Helper to get item display name
const getItemName = (item: SubmissionItem): string => {
const entityData = item.entity_data || item.item_data;
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
return String(entityData.name);
}
return `${item.item_type} #${item.order_index}`;
};
// Helper to get action label
const getActionLabel = (actionType: string): string => {
switch (actionType) {
case 'create': return 'Created';
case 'edit': return 'Edited';
case 'delete': return 'Deleted';
default: return 'Modified';
}
};
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
Item Approvals
</div>
<div className="space-y-2">
{sortedItems.map((item) => {
const approvedAt = (item as any).approved_at;
const itemName = getItemName(item);
const actionLabel = getActionLabel(item.action_type);
return (
<div
key={item.id}
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
>
{/* Approval Icon */}
<div className="flex-shrink-0 mt-0.5">
<CheckCircle2 className="h-4 w-4 text-success" />
</div>
{/* Item Info */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground truncate">
{itemName}
</span>
<Badge variant="outline" className="text-xs">
{actionLabel}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{item.item_type}
</Badge>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
</span>
</div>
{/* Reviewer Info */}
{reviewerProfile && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar className="h-5 w-5">
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
<AvatarFallback className="text-[10px]">
<User className="h-3 w-3" />
</AvatarFallback>
</Avatar>
<span>
Approved by{' '}
<span className="font-medium text-foreground">
{reviewerProfile.display_name || reviewerProfile.username}
</span>
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
});
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';

View File

@@ -1,12 +1,14 @@
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { RefreshButton } from '@/components/ui/refresh-button';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
@@ -55,6 +57,7 @@ export const QueueFilters = ({
isRefreshing = false
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
// Count active filters
const activeFilterCount = [
@@ -76,6 +79,42 @@ export const QueueFilters = ({
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{/* Global toggle for detailed views */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={toggleDetails}
className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{detailsCollapsed ? (
<>
<Maximize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Expand All</span>}
</>
) : (
<>
<Minimize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Collapse All</span>}
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="text-xs">
{detailsCollapsed
? "Show detailed field-by-field view for all items in the queue"
: "Hide detailed field-by-field view for all items in the queue"}
</p>
<p className="text-xs text-muted-foreground mt-1">
This preference is saved to your account
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@@ -85,6 +124,7 @@ export const QueueFilters = ({
</CollapsibleTrigger>
)}
</div>
</div>
<CollapsibleContent className="space-y-4">
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>

View File

@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
import { AuditTrailViewer } from './AuditTrailViewer';
import { RawDataViewer } from './RawDataViewer';
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
interface QueueItemProps {
item: ModerationItem;
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
{item.type === 'content_submission' && (
<div className="mt-6 space-y-4">
<SubmissionMetadataPanel item={item} />
{/* Item-level approval history */}
{item.submission_items && item.submission_items.length > 0 && (
<ItemLevelApprovalHistory
items={item.submission_items}
reviewerProfile={item.reviewer_profile}
/>
)}
<AuditTrailViewer submissionId={item.id} />
</div>
)}

View File

@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
interface SubmissionItemsListProps {
submissionId: string;
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isCollapsed, toggle } = useDetailedViewState();
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
// Helper function to count non-null fields in entity data
const countFields = (data: any): number => {
if (!data || typeof data !== 'object') return 0;
return Object.values(data).filter(value => value !== null && value !== undefined).length;
};
const fetchSubmissionItems = async () => {
try {
// Only show skeleton on initial load, show refreshing indicator on refresh
@@ -126,7 +135,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
}
// Render item with appropriate display component
const renderItem = (item: SubmissionItemData) => {
const renderItem = (item: SubmissionItemData, index: number = 0) => {
// SubmissionItemData from submissions.ts has item_data property
const entityData = item.item_data;
const actionType = item.action_type || 'create';
@@ -188,17 +197,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as ParkSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -211,17 +222,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -234,17 +247,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as CompanySubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -257,17 +272,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideModelSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -280,17 +297,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as TimelineSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -320,9 +339,9 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
)}
{/* Show regular submission items */}
{items.map((item) => (
{items.map((item, index) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
{renderItem(item)}
{renderItem(item, index)}
</div>
))}

View File

@@ -1,9 +1,30 @@
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Content>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.Content
ref={ref}
className={cn(
"overflow-hidden transition-all duration-300 ease-out",
"data-[state=closed]:animate-accordion-up",
"data-[state=open]:animate-accordion-down",
className
)}
{...props}
>
<div className="animate-fade-in">
{children}
</div>
</CollapsiblePrimitive.Content>
));
CollapsibleContent.displayName = "CollapsibleContent";
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/lib/supabaseClient';
import { handleNonCriticalError } from '@/lib/errorHandler';
import type { Json } from '@/integrations/supabase/types';
const STORAGE_KEY = 'detailed-view-collapsed';
interface ModerationPreferences {
detailed_view_collapsed: boolean;
}
interface UseDetailedViewStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
loading: boolean;
}
/**
* Hook to manage detailed view collapsed/expanded state
* Persists to database for authenticated users, localStorage for guests
* Defaults to collapsed to reduce visual clutter
*/
export function useDetailedViewState(): UseDetailedViewStateReturn {
const { user } = useAuth();
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const [loading, setLoading] = useState(true);
// Load preferences on mount
useEffect(() => {
loadPreferences();
}, [user]);
const loadPreferences = async () => {
try {
if (user) {
// Load from database for authenticated users
const { data, error } = await supabase
.from('user_preferences')
.select('moderation_preferences')
.eq('user_id', user.id)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
handleNonCriticalError(error, {
action: 'Load moderation preferences',
userId: user.id,
});
}
// Type assertion needed until Supabase regenerates types after migration
const preferences = (data as any)?.moderation_preferences;
if (preferences) {
const prefs = preferences as ModerationPreferences;
setIsCollapsed(prefs.detailed_view_collapsed ?? true);
}
} else {
// Load from localStorage for guests
try {
const stored = localStorage.getItem(STORAGE_KEY);
setIsCollapsed(stored ? JSON.parse(stored) : true);
} catch (error) {
logger.warn('Error reading detailed view state from localStorage', { error });
}
}
} catch (error) {
logger.warn('Error loading detailed view preferences', { error });
} finally {
setLoading(false);
}
};
const savePreferences = async (collapsed: boolean) => {
try {
if (user) {
// Save to database for authenticated users
const moderationPrefs: ModerationPreferences = {
detailed_view_collapsed: collapsed,
};
const { error } = await supabase
.from('user_preferences')
.upsert({
user_id: user.id,
moderation_preferences: moderationPrefs as unknown as Json,
updated_at: new Date().toISOString(),
}, {
onConflict: 'user_id',
});
if (error) {
handleNonCriticalError(error, {
action: 'Save moderation preferences',
userId: user.id,
});
}
} else {
// Save to localStorage for guests
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
} catch (error) {
logger.warn('Error saving detailed view state to localStorage', { error });
}
}
} catch (error) {
logger.warn('Error saving detailed view preferences', { error });
}
};
const toggle = () => {
const newValue = !isCollapsed;
setIsCollapsed(newValue);
savePreferences(newValue);
};
const setCollapsed = (value: boolean) => {
setIsCollapsed(value);
savePreferences(value);
};
return {
isCollapsed,
toggle,
setCollapsed,
loading,
};
}

View File

@@ -207,8 +207,8 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
p_moderator_id: user.id,
p_submitter_id: submission.user_id,
p_request_id: requestId,
p_trace_id: rootSpan.traceId,
p_parent_span_id: rpcSpan.spanId
p_approval_mode: 'selective',
p_idempotency_key: idempotencyKey
}
);

View File

@@ -0,0 +1,12 @@
-- Add moderation_preferences column to user_preferences table
-- This stores moderator UI preferences like detailed view collapsed state
ALTER TABLE public.user_preferences
ADD COLUMN IF NOT EXISTS moderation_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN public.user_preferences.moderation_preferences IS
'Stores moderator UI preferences like detailed view collapsed state';
-- Add GIN index for efficient JSONB queries
CREATE INDEX IF NOT EXISTS idx_user_preferences_moderation_prefs
ON public.user_preferences USING gin(moderation_preferences);