mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-29 13:07:07 -05:00
Compare commits
16 Commits
b22546e7f2
...
edit/edt-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f3ba350ea | ||
|
|
706e36c847 | ||
|
|
a1beba6996 | ||
|
|
d7158756ef | ||
|
|
3330a8fac9 | ||
|
|
c09a343d08 | ||
|
|
9893567a30 | ||
|
|
771405961f | ||
|
|
437e2b353c | ||
|
|
44a713af62 | ||
|
|
46275e0f1e | ||
|
|
6bd7d24a1b | ||
|
|
72e76e86af | ||
|
|
a35486fb11 | ||
|
|
3d3ae57ee3 | ||
|
|
46c08e10e8 |
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal 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';
|
||||||
@@ -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 { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
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 { RefreshButton } from '@/components/ui/refresh-button';
|
||||||
import { QueueSortControls } from './QueueSortControls';
|
import { QueueSortControls } from './QueueSortControls';
|
||||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||||
|
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||||
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export const QueueFilters = ({
|
|||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}: QueueFiltersProps) => {
|
}: QueueFiltersProps) => {
|
||||||
const { isCollapsed, toggle } = useFilterPanelState();
|
const { isCollapsed, toggle } = useFilterPanelState();
|
||||||
|
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
|
||||||
|
|
||||||
// Count active filters
|
// Count active filters
|
||||||
const activeFilterCount = [
|
const activeFilterCount = [
|
||||||
@@ -76,14 +79,51 @@ export const QueueFilters = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isMobile && (
|
<div className="flex items-center gap-2">
|
||||||
<CollapsibleTrigger asChild>
|
{/* Global toggle for detailed views */}
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<TooltipProvider>
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
<Tooltip>
|
||||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
<TooltipTrigger asChild>
|
||||||
</Button>
|
<Button
|
||||||
</CollapsibleTrigger>
|
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">
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||||
|
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsibleContent className="space-y-4">
|
<CollapsibleContent className="space-y-4">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
|||||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||||
import { RawDataViewer } from './RawDataViewer';
|
import { RawDataViewer } from './RawDataViewer';
|
||||||
|
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||||
|
|
||||||
interface QueueItemProps {
|
interface QueueItemProps {
|
||||||
item: ModerationItem;
|
item: ModerationItem;
|
||||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
|||||||
{item.type === 'content_submission' && (
|
{item.type === 'content_submission' && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<SubmissionMetadataPanel item={item} />
|
<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} />
|
<AuditTrailViewer submissionId={item.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
|||||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||||
|
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
|||||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||||
|
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||||
|
|
||||||
interface SubmissionItemsListProps {
|
interface SubmissionItemsListProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { isCollapsed, toggle } = useDetailedViewState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubmissionItems();
|
fetchSubmissionItems();
|
||||||
}, [submissionId]);
|
}, [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 () => {
|
const fetchSubmissionItems = async () => {
|
||||||
try {
|
try {
|
||||||
// Only show skeleton on initial load, show refreshing indicator on refresh
|
// 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
|
// 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
|
// SubmissionItemData from submissions.ts has item_data property
|
||||||
const entityData = item.item_data;
|
const entityData = item.item_data;
|
||||||
const actionType = item.action_type || 'create';
|
const actionType = item.action_type || 'create';
|
||||||
@@ -188,17 +197,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as ParkSubmissionData}
|
data={entityData as unknown as ParkSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
isCollapsed={isCollapsed}
|
||||||
All Fields (Detailed View)
|
onToggle={toggle}
|
||||||
</div>
|
fieldCount={countFields(entityData)}
|
||||||
|
staggerIndex={index}
|
||||||
|
>
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,17 +222,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideSubmissionData}
|
data={entityData as unknown as RideSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
isCollapsed={isCollapsed}
|
||||||
All Fields (Detailed View)
|
onToggle={toggle}
|
||||||
</div>
|
fieldCount={countFields(entityData)}
|
||||||
|
staggerIndex={index}
|
||||||
|
>
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -234,17 +247,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as CompanySubmissionData}
|
data={entityData as unknown as CompanySubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
isCollapsed={isCollapsed}
|
||||||
All Fields (Detailed View)
|
onToggle={toggle}
|
||||||
</div>
|
fieldCount={countFields(entityData)}
|
||||||
|
staggerIndex={index}
|
||||||
|
>
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,17 +272,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideModelSubmissionData}
|
data={entityData as unknown as RideModelSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
isCollapsed={isCollapsed}
|
||||||
All Fields (Detailed View)
|
onToggle={toggle}
|
||||||
</div>
|
fieldCount={countFields(entityData)}
|
||||||
|
staggerIndex={index}
|
||||||
|
>
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,17 +297,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as TimelineSubmissionData}
|
data={entityData as unknown as TimelineSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
isCollapsed={isCollapsed}
|
||||||
All Fields (Detailed View)
|
onToggle={toggle}
|
||||||
</div>
|
fieldCount={countFields(entityData)}
|
||||||
|
staggerIndex={index}
|
||||||
|
>
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -320,9 +339,9 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show regular submission items */}
|
{/* 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' : ''}>
|
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||||
{renderItem(item)}
|
{renderItem(item, index)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
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 };
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
|
|||||||
129
src/hooks/useDetailedViewState.ts
Normal file
129
src/hooks/useDetailedViewState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -207,8 +207,8 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
|
|||||||
p_moderator_id: user.id,
|
p_moderator_id: user.id,
|
||||||
p_submitter_id: submission.user_id,
|
p_submitter_id: submission.user_id,
|
||||||
p_request_id: requestId,
|
p_request_id: requestId,
|
||||||
p_trace_id: rootSpan.traceId,
|
p_approval_mode: 'selective',
|
||||||
p_parent_span_id: rpcSpan.spanId
|
p_idempotency_key: idempotencyKey
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user