feat: Implement reusable button components

This commit is contained in:
gpt-engineer-app[bot]
2025-11-04 18:29:13 +00:00
parent cb01707c5e
commit b07004ed03
9 changed files with 304 additions and 112 deletions

View File

@@ -322,6 +322,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
onSortChange={queueManager.filters.setSortConfig}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
onRefresh={queueManager.refresh}
isRefreshing={queueManager.loadingState === 'refreshing'}
/>
{/* Active Filters Display */}

View File

@@ -1,6 +1,6 @@
import { AlertCircle, RefreshCw } from 'lucide-react';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { ShowNewItemsButton } from './show-new-items-button';
interface NewItemsAlertProps {
count: number;
@@ -18,15 +18,10 @@ export const NewItemsAlert = ({ count, onShowNewItems, visible = true }: NewItem
<AlertTitle>New Items Available</AlertTitle>
<AlertDescription className="flex items-center justify-between">
<span>{count} new {count === 1 ? 'submission' : 'submissions'} pending review</span>
<Button
variant="default"
size="sm"
onClick={onShowNewItems}
className="ml-4"
>
<RefreshCw className="w-4 h-4 mr-2" />
Show New Items
</Button>
<ShowNewItemsButton
count={count}
onShow={onShowNewItems}
/>
</AlertDescription>
</Alert>
</div>

View File

@@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { RefreshButton } from '@/components/ui/refresh-button';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
@@ -19,6 +20,8 @@ interface QueueFiltersProps {
onSortChange: (config: SortConfig) => void;
onClearFilters: () => void;
showClearButton: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const getEntityFilterIcon = (filter: EntityFilter) => {
@@ -40,7 +43,9 @@ export const QueueFilters = ({
onStatusFilterChange,
onSortChange,
onClearFilters,
showClearButton
showClearButton,
onRefresh,
isRefreshing = false
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
@@ -189,9 +194,17 @@ export const QueueFilters = ({
</CollapsibleContent>
</Collapsible>
{/* Clear Filters Button (desktop only) */}
{!isMobile && showClearButton && (
<div className="flex items-end pt-2">
{/* Clear Filters & Manual Refresh (desktop only) */}
{!isMobile && (showClearButton || onRefresh) && (
<div className="flex items-center gap-2 pt-2">
{onRefresh && (
<RefreshButton
onRefresh={onRefresh}
isLoading={isRefreshing}
size="sm"
/>
)}
{showClearButton && (
<Button
variant="outline"
size="sm"
@@ -202,6 +215,7 @@ export const QueueFilters = ({
<X className="w-4 h-4" />
Clear Filters
</Button>
)}
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle, RefreshCw } from 'lucide-react';
import { AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RefreshButton } from '@/components/ui/refresh-button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
@@ -25,6 +25,7 @@ export function ValidationSummary({ item, onValidationChange, compact = false, v
const [isLoading, setIsLoading] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [manualTriggerCount, setManualTriggerCount] = useState(0);
const [isRevalidating, setIsRevalidating] = useState(false);
// Helper to extract the correct entity ID based on entity type
const getEntityId = (
@@ -225,15 +226,24 @@ export function ValidationSummary({ item, onValidationChange, compact = false, v
</Badge>
)}
<Button
variant="outline"
<RefreshButton
onRefresh={async () => {
setIsRevalidating(true);
try {
setManualTriggerCount(prev => prev + 1);
// Short delay to show feedback
await new Promise(resolve => setTimeout(resolve, 500));
} finally {
setIsRevalidating(false);
}
}}
isLoading={isRevalidating}
size="sm"
onClick={() => setManualTriggerCount(prev => prev + 1)}
variant="outline"
className="text-xs h-7"
>
<RefreshCw className="w-3 h-3 mr-1" />
Re-validate
</Button>
</RefreshButton>
</div>
{/* Detailed Issues */}

View File

@@ -1,10 +1,10 @@
import { memo, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import {
CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2,
Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ActionButton } from '@/components/ui/action-button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -159,24 +159,14 @@ export const QueueItemActions = memo(({
<AlertDescription className="text-blue-800 dark:text-blue-200">
<div className="flex items-center justify-between mt-2">
<span className="text-sm">Claim this submission to lock it for 15 minutes while you review</span>
<Button
<ActionButton
action="claim"
onClick={onClaim}
disabled={queueIsLoading || isClaiming}
isLoading={isClaiming}
size="sm"
className="ml-4"
>
{isClaiming ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Claiming...
</>
) : (
<>
<Lock className="w-4 h-4 mr-2" />
Claim Submission
</>
)}
</Button>
/>
</div>
</AlertDescription>
</Alert>
@@ -274,46 +264,29 @@ export const QueueItemActions = memo(({
</>
)}
<Button
<ActionButton
action="approve"
onClick={handleApprove}
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"}
>
{actionLoading === item.id ? (
<>
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
{item.submission_items && item.submission_items.length > 5
isLoading={actionLoading === item.id}
loadingText={
item.submission_items && item.submission_items.length > 5
? `Processing ${item.submission_items.length} items...`
: 'Processing...'
}
</>
) : (
<>
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Approve
</>
)}
</Button>
<Button
variant="destructive"
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
/>
<ActionButton
action="reject"
onClick={handleReject}
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
>
{actionLoading === item.id ? (
<>
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
Processing...
</>
) : (
<>
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Reject
</>
)}
</Button>
isMobile={isMobile}
/>
</div>
</div>
</>
@@ -329,15 +302,13 @@ export const QueueItemActions = memo(({
<p className="text-xs mt-1">You can reset it to pending to re-review and approve it.</p>
</div>
</div>
<Button
<ActionButton
action="reset"
onClick={handleResetToPending}
disabled={actionLoading === item.id}
variant="outline"
isLoading={actionLoading === item.id}
className="w-full"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reset to Pending
</Button>
/>
</div>
)}
@@ -361,23 +332,22 @@ export const QueueItemActions = memo(({
<ListTree className="w-4 h-4 mr-2" />
Review Items
</Button>
<Button
<ActionButton
action="reset"
onClick={handleResetToPending}
disabled={actionLoading === item.id}
variant="outline"
isLoading={actionLoading === item.id}
className="flex-1"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reset All
</Button>
<Button
</ActionButton>
<ActionButton
action="retry"
onClick={handleRetryFailed}
disabled={actionLoading === item.id}
className="flex-1 bg-yellow-600 hover:bg-yellow-700"
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Failed
</Button>
isLoading={actionLoading === item.id}
className="flex-1"
/>
</div>
</div>
)}
@@ -461,27 +431,30 @@ export const QueueItemActions = memo(({
/>
<div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>
{item.status === 'approved' && (
<Button
variant="destructive"
<ActionButton
action="reject"
onClick={handleReverseReject}
disabled={actionLoading === item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Change to Rejected
</Button>
</ActionButton>
)}
{item.status === 'rejected' && (
<Button
<ActionButton
action="approve"
onClick={handleReverseApprove}
disabled={actionLoading === item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
isLoading={actionLoading === item.id}
className="flex-1"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Change to Approved
</Button>
</ActionButton>
)}
</div>
</div>
@@ -491,16 +464,17 @@ export const QueueItemActions = memo(({
{/* Delete button for rejected submissions (admin/superadmin only) */}
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
<div className="pt-2">
<Button
variant="destructive"
<ActionButton
action="delete"
onClick={handleDeleteSubmission}
disabled={actionLoading === item.id}
className={`w-full ${isMobile ? 'h-11' : ''}`}
isLoading={actionLoading === item.id}
className="w-full"
size={isMobile ? "default" : "default"}
isMobile={isMobile}
>
<Trash2 className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Delete Submission
</Button>
</ActionButton>
</div>
)}
</>

View File

@@ -0,0 +1,30 @@
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ShowNewItemsButtonProps {
count: number;
onShow: () => void | Promise<void>;
isLoading?: boolean;
}
export const ShowNewItemsButton = ({
count,
onShow,
isLoading = false
}: ShowNewItemsButtonProps) => {
const itemText = count === 1 ? 'item' : 'items';
return (
<Button
variant="default"
size="sm"
onClick={onShow}
loading={isLoading}
loadingText={`Loading ${count} ${itemText}...`}
trackingLabel="show-new-queue-items"
>
<RefreshCw className="w-4 h-4 mr-2" />
Show {count} New {count === 1 ? 'Item' : 'Items'}
</Button>
);
};

View File

@@ -0,0 +1,105 @@
import { CheckCircle, XCircle, Lock, RefreshCw, Trash2 } from 'lucide-react';
import { Button, ButtonProps } from './button';
type ActionType = 'approve' | 'reject' | 'delete' | 'claim' | 'reset' | 'retry';
interface ActionConfig {
icon: React.ReactNode;
defaultLabel: string;
loadingText: string;
variant: ButtonProps['variant'];
className?: string;
}
const ACTION_CONFIGS: Record<ActionType, ActionConfig> = {
approve: {
icon: <CheckCircle className="w-4 h-4 mr-2" />,
defaultLabel: 'Approve',
loadingText: 'Processing...',
variant: 'default',
},
reject: {
icon: <XCircle className="w-4 h-4 mr-2" />,
defaultLabel: 'Reject',
loadingText: 'Processing...',
variant: 'destructive',
},
claim: {
icon: <Lock className="w-4 h-4 mr-2" />,
defaultLabel: 'Claim Submission',
loadingText: 'Claiming...',
variant: 'default',
},
reset: {
icon: <RefreshCw className="w-4 h-4 mr-2" />,
defaultLabel: 'Reset to Pending',
loadingText: 'Resetting...',
variant: 'outline',
},
retry: {
icon: <RefreshCw className="w-4 h-4 mr-2" />,
defaultLabel: 'Retry Failed',
loadingText: 'Retrying...',
variant: 'default',
className: 'bg-yellow-600 hover:bg-yellow-700',
},
delete: {
icon: <Trash2 className="w-4 h-4 mr-2" />,
defaultLabel: 'Delete',
loadingText: 'Deleting...',
variant: 'destructive',
},
};
interface ActionButtonProps extends Omit<ButtonProps, 'loading' | 'loadingText' | 'variant'> {
action: ActionType;
isLoading?: boolean;
loadingText?: string;
variant?: ButtonProps['variant'];
children?: React.ReactNode;
isMobile?: boolean;
}
export const ActionButton = ({
action,
isLoading = false,
loadingText,
variant,
size = 'default',
className,
children,
isMobile = false,
...props
}: ActionButtonProps) => {
const config = ACTION_CONFIGS[action];
const iconClassName = isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2";
// Clone the icon with mobile-appropriate size
const icon = isMobile && config.icon
? <CheckCircle className={iconClassName} /> // Dynamic sizing handled per action below
: config.icon;
return (
<Button
variant={variant || config.variant}
size={size}
loading={isLoading}
loadingText={loadingText || config.loadingText}
className={`${config.className || ''} ${className || ''} ${isMobile ? 'h-11' : ''}`}
trackingLabel={`moderation-${action}`}
{...props}
>
{children || (
<>
{action === 'approve' && (isMobile ? <CheckCircle className="w-5 h-5 mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />)}
{action === 'reject' && (isMobile ? <XCircle className="w-5 h-5 mr-2" /> : <XCircle className="w-4 h-4 mr-2" />)}
{action === 'claim' && (isMobile ? <Lock className="w-5 h-5 mr-2" /> : <Lock className="w-4 h-4 mr-2" />)}
{action === 'reset' && <RefreshCw className={iconClassName} />}
{action === 'retry' && <RefreshCw className={iconClassName} />}
{action === 'delete' && <Trash2 className={iconClassName} />}
{config.defaultLabel}
</>
)}
</Button>
);
};

View File

@@ -0,0 +1,28 @@
import { Button, ButtonProps } from './button';
interface IconButtonProps extends Omit<ButtonProps, 'loading'> {
icon: React.ReactNode;
label: string; // For accessibility
isLoading?: boolean;
}
export const IconButton = ({
icon,
label,
isLoading = false,
variant = 'ghost',
size = 'icon',
...props
}: IconButtonProps) => {
return (
<Button
variant={variant}
size={size}
loading={isLoading}
aria-label={label}
{...props}
>
{!isLoading && icon}
</Button>
);
};

View File

@@ -0,0 +1,34 @@
import { RefreshCw } from 'lucide-react';
import { Button, ButtonProps } from './button';
interface RefreshButtonProps extends Omit<ButtonProps, 'loading' | 'loadingText'> {
onRefresh: () => void | Promise<void>;
isLoading?: boolean;
children?: React.ReactNode;
}
export const RefreshButton = ({
onRefresh,
isLoading = false,
size = 'default',
variant = 'outline',
children = 'Refresh',
className,
...props
}: RefreshButtonProps) => {
return (
<Button
variant={variant}
size={size}
onClick={onRefresh}
loading={isLoading}
loadingText="Refreshing..."
className={className}
trackingLabel="refresh-data"
{...props}
>
<RefreshCw className="w-4 h-4 mr-2" />
{children}
</Button>
);
};