mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 23:31:12 -05:00
feat: Implement reusable button components
This commit is contained in:
@@ -322,6 +322,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
onSortChange={queueManager.filters.setSortConfig}
|
onSortChange={queueManager.filters.setSortConfig}
|
||||||
onClearFilters={queueManager.filters.clearFilters}
|
onClearFilters={queueManager.filters.clearFilters}
|
||||||
showClearButton={queueManager.filters.hasActiveFilters}
|
showClearButton={queueManager.filters.hasActiveFilters}
|
||||||
|
onRefresh={queueManager.refresh}
|
||||||
|
isRefreshing={queueManager.loadingState === 'refreshing'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
{/* Active Filters Display */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { ShowNewItemsButton } from './show-new-items-button';
|
||||||
|
|
||||||
interface NewItemsAlertProps {
|
interface NewItemsAlertProps {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -18,15 +18,10 @@ export const NewItemsAlert = ({ count, onShowNewItems, visible = true }: NewItem
|
|||||||
<AlertTitle>New Items Available</AlertTitle>
|
<AlertTitle>New Items Available</AlertTitle>
|
||||||
<AlertDescription className="flex items-center justify-between">
|
<AlertDescription className="flex items-center justify-between">
|
||||||
<span>{count} new {count === 1 ? 'submission' : 'submissions'} pending review</span>
|
<span>{count} new {count === 1 ? 'submission' : 'submissions'} pending review</span>
|
||||||
<Button
|
<ShowNewItemsButton
|
||||||
variant="default"
|
count={count}
|
||||||
size="sm"
|
onShow={onShowNewItems}
|
||||||
onClick={onShowNewItems}
|
/>
|
||||||
className="ml-4"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Show New Items
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
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 { 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 type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||||
@@ -19,6 +20,8 @@ interface QueueFiltersProps {
|
|||||||
onSortChange: (config: SortConfig) => void;
|
onSortChange: (config: SortConfig) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
showClearButton: boolean;
|
showClearButton: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
isRefreshing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntityFilterIcon = (filter: EntityFilter) => {
|
const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||||
@@ -40,7 +43,9 @@ export const QueueFilters = ({
|
|||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
showClearButton
|
showClearButton,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing = false
|
||||||
}: QueueFiltersProps) => {
|
}: QueueFiltersProps) => {
|
||||||
const { isCollapsed, toggle } = useFilterPanelState();
|
const { isCollapsed, toggle } = useFilterPanelState();
|
||||||
|
|
||||||
@@ -189,19 +194,28 @@ export const QueueFilters = ({
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* Clear Filters Button (desktop only) */}
|
{/* Clear Filters & Manual Refresh (desktop only) */}
|
||||||
{!isMobile && showClearButton && (
|
{!isMobile && (showClearButton || onRefresh) && (
|
||||||
<div className="flex items-end pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button
|
{onRefresh && (
|
||||||
variant="outline"
|
<RefreshButton
|
||||||
size="sm"
|
onRefresh={onRefresh}
|
||||||
onClick={onClearFilters}
|
isLoading={isRefreshing}
|
||||||
className="flex items-center gap-2"
|
size="sm"
|
||||||
aria-label="Clear all filters"
|
/>
|
||||||
>
|
)}
|
||||||
<X className="w-4 h-4" />
|
{showClearButton && (
|
||||||
Clear Filters
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
aria-label="Clear all filters"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
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 { 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
|
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
|
||||||
@@ -25,6 +25,7 @@ export function ValidationSummary({ item, onValidationChange, compact = false, v
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [manualTriggerCount, setManualTriggerCount] = useState(0);
|
const [manualTriggerCount, setManualTriggerCount] = useState(0);
|
||||||
|
const [isRevalidating, setIsRevalidating] = useState(false);
|
||||||
|
|
||||||
// Helper to extract the correct entity ID based on entity type
|
// Helper to extract the correct entity ID based on entity type
|
||||||
const getEntityId = (
|
const getEntityId = (
|
||||||
@@ -225,15 +226,24 @@ export function ValidationSummary({ item, onValidationChange, compact = false, v
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<RefreshButton
|
||||||
variant="outline"
|
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"
|
size="sm"
|
||||||
onClick={() => setManualTriggerCount(prev => prev + 1)}
|
variant="outline"
|
||||||
className="text-xs h-7"
|
className="text-xs h-7"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
|
||||||
Re-validate
|
Re-validate
|
||||||
</Button>
|
</RefreshButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Issues */}
|
{/* Detailed Issues */}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import {
|
import {
|
||||||
CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2,
|
AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
|
||||||
Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ActionButton } from '@/components/ui/action-button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
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">
|
<AlertDescription className="text-blue-800 dark:text-blue-200">
|
||||||
<div className="flex items-center justify-between mt-2">
|
<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>
|
<span className="text-sm">Claim this submission to lock it for 15 minutes while you review</span>
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="claim"
|
||||||
onClick={onClaim}
|
onClick={onClaim}
|
||||||
disabled={queueIsLoading || isClaiming}
|
disabled={queueIsLoading || isClaiming}
|
||||||
|
isLoading={isClaiming}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="ml-4"
|
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>
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -274,46 +264,29 @@ export const QueueItemActions = memo(({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="approve"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
isLoading={actionLoading === item.id}
|
||||||
|
loadingText={
|
||||||
|
item.submission_items && item.submission_items.length > 5
|
||||||
|
? `Processing ${item.submission_items.length} items...`
|
||||||
|
: 'Processing...'
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
isMobile={isMobile}
|
||||||
{actionLoading === item.id ? (
|
/>
|
||||||
<>
|
<ActionButton
|
||||||
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
|
action="reject"
|
||||||
{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"
|
|
||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
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"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
isMobile={isMobile}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p className="text-xs mt-1">You can reset it to pending to re-review and approve it.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="reset"
|
||||||
onClick={handleResetToPending}
|
onClick={handleResetToPending}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
variant="outline"
|
isLoading={actionLoading === item.id}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
/>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Reset to Pending
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -361,23 +332,22 @@ export const QueueItemActions = memo(({
|
|||||||
<ListTree className="w-4 h-4 mr-2" />
|
<ListTree className="w-4 h-4 mr-2" />
|
||||||
Review Items
|
Review Items
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="reset"
|
||||||
onClick={handleResetToPending}
|
onClick={handleResetToPending}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
variant="outline"
|
isLoading={actionLoading === item.id}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Reset All
|
Reset All
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="retry"
|
||||||
onClick={handleRetryFailed}
|
onClick={handleRetryFailed}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
className="flex-1 bg-yellow-600 hover:bg-yellow-700"
|
isLoading={actionLoading === item.id}
|
||||||
>
|
className="flex-1"
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
/>
|
||||||
Retry Failed
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,27 +431,30 @@ export const QueueItemActions = memo(({
|
|||||||
/>
|
/>
|
||||||
<div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>
|
<div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>
|
||||||
{item.status === 'approved' && (
|
{item.status === 'approved' && (
|
||||||
<Button
|
<ActionButton
|
||||||
variant="destructive"
|
action="reject"
|
||||||
onClick={handleReverseReject}
|
onClick={handleReverseReject}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
isLoading={actionLoading === item.id}
|
||||||
|
className="flex-1"
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
|
isMobile={isMobile}
|
||||||
>
|
>
|
||||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
|
||||||
Change to Rejected
|
Change to Rejected
|
||||||
</Button>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
{item.status === 'rejected' && (
|
{item.status === 'rejected' && (
|
||||||
<Button
|
<ActionButton
|
||||||
|
action="approve"
|
||||||
onClick={handleReverseApprove}
|
onClick={handleReverseApprove}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
isLoading={actionLoading === item.id}
|
||||||
|
className="flex-1"
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
|
isMobile={isMobile}
|
||||||
>
|
>
|
||||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
|
||||||
Change to Approved
|
Change to Approved
|
||||||
</Button>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,16 +464,17 @@ export const QueueItemActions = memo(({
|
|||||||
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
||||||
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
|
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin || isSuperuser) && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<ActionButton
|
||||||
variant="destructive"
|
action="delete"
|
||||||
onClick={handleDeleteSubmission}
|
onClick={handleDeleteSubmission}
|
||||||
disabled={actionLoading === item.id}
|
disabled={actionLoading === item.id}
|
||||||
className={`w-full ${isMobile ? 'h-11' : ''}`}
|
isLoading={actionLoading === item.id}
|
||||||
|
className="w-full"
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
|
isMobile={isMobile}
|
||||||
>
|
>
|
||||||
<Trash2 className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
|
||||||
Delete Submission
|
Delete Submission
|
||||||
</Button>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
30
src/components/moderation/show-new-items-button.tsx
Normal file
30
src/components/moderation/show-new-items-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
src/components/ui/action-button.tsx
Normal file
105
src/components/ui/action-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/components/ui/icon-button.tsx
Normal file
28
src/components/ui/icon-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/components/ui/refresh-button.tsx
Normal file
34
src/components/ui/refresh-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user