diff --git a/src/components/ConflictResolutionPage.tsx b/src/components/ConflictResolutionPage.tsx index be1fbb0..8a0dc0f 100644 --- a/src/components/ConflictResolutionPage.tsx +++ b/src/components/ConflictResolutionPage.tsx @@ -1,32 +1,18 @@ import { useState, useEffect } from 'react' import { useConflictResolution } from '@/hooks/use-conflict-resolution' import { ConflictItem, ConflictResolutionStrategy } from '@/types/conflicts' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' -import { ScrollArea } from '@/components/ui/scroll-area' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { ConflictCard } from '@/components/ConflictCard' -import { ConflictDetailsDialog } from '@/components/ConflictDetailsDialog' -import { - Warning, - ArrowsClockwise, - CheckCircle, - XCircle, - Database, - Cloud, - ArrowsLeftRight, - Trash, - MagnifyingGlass, -} from '@phosphor-icons/react' -import { motion, AnimatePresence } from 'framer-motion' +import conflictResolutionCopy from '@/data/conflict-resolution.json' +import { ConflictResolutionHeader } from '@/components/conflict-resolution/ConflictResolutionHeader' +import { ConflictResolutionStatsSection } from '@/components/conflict-resolution/ConflictResolutionStats' +import { ConflictResolutionBulkActions } from '@/components/conflict-resolution/ConflictResolutionBulkActions' +import { ConflictResolutionFilters } from '@/components/conflict-resolution/ConflictResolutionFilters' +import { ConflictResolutionList } from '@/components/conflict-resolution/ConflictResolutionList' +import { ConflictResolutionError } from '@/components/conflict-resolution/ConflictResolutionError' +import { ConflictResolutionDetails } from '@/components/conflict-resolution/ConflictResolutionDetails' +import type { + ConflictResolutionCopy, + ConflictResolutionFilters as ConflictResolutionFilterType, +} from '@/components/conflict-resolution/types' import { toast } from 'sonner' export function ConflictResolutionPage() { @@ -47,7 +33,8 @@ export function ConflictResolutionPage() { const [selectedConflict, setSelectedConflict] = useState(null) const [detailsDialogOpen, setDetailsDialogOpen] = useState(false) - const [filterType, setFilterType] = useState('all') + const [filterType, setFilterType] = useState('all') + const copy = conflictResolutionCopy as ConflictResolutionCopy useEffect(() => { detect().catch(() => {}) @@ -57,30 +44,34 @@ export function ConflictResolutionPage() { try { const detected = await detect() if (detected.length === 0) { - toast.success('No conflicts detected') + toast.success(copy.toasts.noConflictsDetected) } else { - toast.info(`Found ${detected.length} conflict${detected.length === 1 ? '' : 's'}`) + const label = + detected.length === 1 ? copy.labels.conflictSingular : copy.labels.conflictPlural + toast.info(copy.toasts.foundConflicts + .replace('{count}', String(detected.length)) + .replace('{label}', label)) } } catch (err: any) { - toast.error(err.message || 'Failed to detect conflicts') + toast.error(err.message || copy.toasts.detectFailed) } } const handleResolve = async (conflictId: string, strategy: ConflictResolutionStrategy) => { try { await resolve(conflictId, strategy) - toast.success(`Conflict resolved using ${strategy} version`) + toast.success(copy.toasts.resolved.replace('{strategy}', strategy)) } catch (err: any) { - toast.error(err.message || 'Failed to resolve conflict') + toast.error(err.message || copy.toasts.resolveFailed) } } const handleResolveAll = async (strategy: ConflictResolutionStrategy) => { try { await resolveAll(strategy) - toast.success(`All conflicts resolved using ${strategy} version`) + toast.success(copy.toasts.resolvedAll.replace('{strategy}', strategy)) } catch (err: any) { - toast.error(err.message || 'Failed to resolve all conflicts') + toast.error(err.message || copy.toasts.resolveAllFailed) } } @@ -97,253 +88,51 @@ export function ConflictResolutionPage() {
-
-
-

- Conflict Resolution -

-

- Manage sync conflicts between local and remote data -

-
- -
- - - {hasConflicts && ( - - )} -
-
- -
- - -
-
-
{stats.totalConflicts}
-
Total Conflicts
-
- -
-
-
- - - -
-
-
{stats.conflictsByType.files || 0}
-
Files
-
- -
-
-
- - - -
-
-
{stats.conflictsByType.models || 0}
-
Models
-
- -
-
-
- - - -
-
-
- {(stats.conflictsByType.components || 0) + - (stats.conflictsByType.workflows || 0)} -
-
Other
-
- -
-
-
-
- + + {hasConflicts && ( - - - - - Bulk Resolution - - - Apply a resolution strategy to all conflicts at once - - - - - - - - - -
- Auto-resolve: - -
-
-
+ setAutoResolve(value)} + /> )}
- {hasConflicts && ( -
-
- - Filter by type: - -
+ - - {filteredConflicts.length} conflict{filteredConflicts.length === 1 ? '' : 's'} - -
- )} + - -
- - {filteredConflicts.length > 0 ? ( - filteredConflicts.map((conflict) => ( - - )) - ) : hasConflicts ? ( - - -

- No conflicts found for this filter -

-
- ) : ( - - -

No Conflicts Detected

-

- Your local and remote data are in sync -

- -
- )} -
-
-
- - {error && ( - - - - -
-
Error
-
{error}
-
-
-
-
- )} +
- void + onAutoResolveChange: (strategy: ConflictResolutionStrategy | null) => void +} + +export function ConflictResolutionBulkActions({ + copy, + detectingConflicts, + resolvingConflict, + autoResolveStrategy, + onResolveAll, + onAutoResolveChange, +}: ConflictResolutionBulkActionsProps) { + return ( + + + + + {copy.bulk.title} + + {copy.bulk.description} + + + + + + + + +
+ {copy.bulk.autoResolveLabel} + +
+
+
+ ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionDetails.tsx b/src/components/conflict-resolution/ConflictResolutionDetails.tsx new file mode 100644 index 0000000..0e5b878 --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionDetails.tsx @@ -0,0 +1,29 @@ +import type { ConflictResolutionItem, ConflictResolveHandler } from '@/components/conflict-resolution/types' + +import { ConflictDetailsDialog } from '@/components/ConflictDetailsDialog' + +interface ConflictResolutionDetailsProps { + conflict: ConflictResolutionItem | null + open: boolean + onOpenChange: (open: boolean) => void + onResolve: ConflictResolveHandler + isResolving: boolean +} + +export function ConflictResolutionDetails({ + conflict, + open, + onOpenChange, + onResolve, + isResolving, +}: ConflictResolutionDetailsProps) { + return ( + + ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionError.tsx b/src/components/conflict-resolution/ConflictResolutionError.tsx new file mode 100644 index 0000000..d6e229c --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionError.tsx @@ -0,0 +1,30 @@ +import type { ConflictResolutionCopy } from '@/components/conflict-resolution/types' + +import { Card, CardContent } from '@/components/ui/card' +import { XCircle } from '@phosphor-icons/react' +import { motion } from 'framer-motion' + +interface ConflictResolutionErrorProps { + copy: ConflictResolutionCopy + error: string | null +} + +export function ConflictResolutionError({ copy, error }: ConflictResolutionErrorProps) { + if (!error) { + return null + } + + return ( + + + + +
+
{copy.error.title}
+
{error}
+
+
+
+
+ ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionFilters.tsx b/src/components/conflict-resolution/ConflictResolutionFilters.tsx new file mode 100644 index 0000000..bb336d9 --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionFilters.tsx @@ -0,0 +1,65 @@ +import type { + ConflictResolutionCopy, + ConflictResolutionFilters, +} from '@/components/conflict-resolution/types' + +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { MagnifyingGlass } from '@phosphor-icons/react' + +interface ConflictResolutionFiltersProps { + copy: ConflictResolutionCopy + hasConflicts: boolean + filterType: ConflictResolutionFilters + onFilterChange: (value: ConflictResolutionFilters) => void + conflictCount: number +} + +export function ConflictResolutionFilters({ + copy, + hasConflicts, + filterType, + onFilterChange, + conflictCount, +}: ConflictResolutionFiltersProps) { + if (!hasConflicts) { + return null + } + + const label = conflictCount === 1 ? copy.labels.conflictSingular : copy.labels.conflictPlural + + return ( +
+
+ + {copy.filters.label} + +
+ + + {copy.badges.conflictCount + .replace('{count}', String(conflictCount)) + .replace('{label}', label)} + +
+ ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionHeader.tsx b/src/components/conflict-resolution/ConflictResolutionHeader.tsx new file mode 100644 index 0000000..a00e076 --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionHeader.tsx @@ -0,0 +1,43 @@ +import type { ConflictResolutionCopy } from '@/components/conflict-resolution/types' + +import { Button } from '@/components/ui/button' +import { ArrowsClockwise, Trash } from '@phosphor-icons/react' + +interface ConflictResolutionHeaderProps { + copy: ConflictResolutionCopy + hasConflicts: boolean + detectingConflicts: boolean + onDetect: () => void + onClear: () => void +} + +export function ConflictResolutionHeader({ + copy, + hasConflicts, + detectingConflicts, + onDetect, + onClear, +}: ConflictResolutionHeaderProps) { + return ( +
+
+

{copy.header.title}

+

{copy.header.description}

+
+ +
+ + + {hasConflicts && ( + + )} +
+
+ ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionList.tsx b/src/components/conflict-resolution/ConflictResolutionList.tsx new file mode 100644 index 0000000..242e32a --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionList.tsx @@ -0,0 +1,76 @@ +import type { + ConflictResolutionCopy, + ConflictResolutionItem, + ConflictResolveHandler, +} from '@/components/conflict-resolution/types' + +import { ScrollArea } from '@/components/ui/scroll-area' +import { ConflictCard } from '@/components/ConflictCard' +import { Button } from '@/components/ui/button' +import { AnimatePresence, motion } from 'framer-motion' +import { CheckCircle, XCircle, ArrowsClockwise } from '@phosphor-icons/react' + +interface ConflictResolutionListProps { + copy: ConflictResolutionCopy + conflicts: ConflictResolutionItem[] + hasConflicts: boolean + isDetecting: boolean + resolvingConflict: string | null + onResolve: ConflictResolveHandler + onViewDetails: (conflict: ConflictResolutionItem) => void + onDetect: () => void +} + +export function ConflictResolutionList({ + copy, + conflicts, + hasConflicts, + isDetecting, + resolvingConflict, + onResolve, + onViewDetails, + onDetect, +}: ConflictResolutionListProps) { + return ( + +
+ + {conflicts.length > 0 ? ( + conflicts.map((conflict) => ( + + )) + ) : hasConflicts ? ( + + +

{copy.emptyStates.filtered}

+
+ ) : ( + + +

{copy.emptyStates.noConflictsTitle}

+

{copy.emptyStates.noConflictsDescription}

+ +
+ )} +
+
+
+ ) +} diff --git a/src/components/conflict-resolution/ConflictResolutionStats.tsx b/src/components/conflict-resolution/ConflictResolutionStats.tsx new file mode 100644 index 0000000..1cb9ca0 --- /dev/null +++ b/src/components/conflict-resolution/ConflictResolutionStats.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react' +import type { ConflictResolutionCopy, ConflictResolutionStats } from '@/components/conflict-resolution/types' + +import { Card, CardContent } from '@/components/ui/card' +import { Warning, Database, Cloud } from '@phosphor-icons/react' + +interface ConflictResolutionStatsProps { + copy: ConflictResolutionCopy + stats: ConflictResolutionStats +} + +function StatsCard({ count, label, icon }: { count: number; label: string; icon: ReactNode }) { + return ( + + +
+
+
{count}
+
{label}
+
+ {icon} +
+
+
+ ) +} + +export function ConflictResolutionStatsSection({ copy, stats }: ConflictResolutionStatsProps) { + const otherCount = + (stats.conflictsByType.components || 0) + (stats.conflictsByType.workflows || 0) + + return ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) +} diff --git a/src/components/conflict-resolution/types.ts b/src/components/conflict-resolution/types.ts new file mode 100644 index 0000000..0c5f4c9 --- /dev/null +++ b/src/components/conflict-resolution/types.ts @@ -0,0 +1,83 @@ +import type { ConflictItem, ConflictResolutionStrategy, ConflictStats, EntityType } from '@/types/conflicts' + +export interface ConflictResolutionCopy { + header: { + title: string + description: string + } + buttons: { + detect: string + clearAll: string + keepAllLocal: string + keepAllRemote: string + mergeAll: string + checkAgain: string + } + stats: { + total: string + files: string + models: string + other: string + } + bulk: { + title: string + description: string + autoResolveLabel: string + autoResolveOptions: { + none: string + local: string + remote: string + merge: string + } + } + filters: { + label: string + allTypes: string + files: string + models: string + components: string + workflows: string + lambdas: string + componentTrees: string + } + badges: { + conflictCount: string + } + emptyStates: { + filtered: string + noConflictsTitle: string + noConflictsDescription: string + } + labels: { + conflictSingular: string + conflictPlural: string + } + toasts: { + noConflictsDetected: string + foundConflicts: string + detectFailed: string + resolved: string + resolveFailed: string + resolvedAll: string + resolveAllFailed: string + } + error: { + title: string + } +} + +export type ConflictResolutionFilters = EntityType | 'all' + +export type ConflictResolutionStats = ConflictStats + +export type ConflictResolutionItem = ConflictItem + +export type ConflictResolveHandler = ( + conflictId: string, + strategy: ConflictResolutionStrategy, +) => Promise + +export type ConflictResolveAllHandler = ( + strategy: ConflictResolutionStrategy, +) => Promise + diff --git a/src/data/conflict-resolution.json b/src/data/conflict-resolution.json new file mode 100644 index 0000000..21360d6 --- /dev/null +++ b/src/data/conflict-resolution.json @@ -0,0 +1,65 @@ +{ + "header": { + "title": "Conflict Resolution", + "description": "Manage sync conflicts between local and remote data" + }, + "buttons": { + "detect": "Detect Conflicts", + "clearAll": "Clear All", + "keepAllLocal": "Keep All Local", + "keepAllRemote": "Keep All Remote", + "mergeAll": "Merge All", + "checkAgain": "Check Again" + }, + "stats": { + "total": "Total Conflicts", + "files": "Files", + "models": "Models", + "other": "Other" + }, + "bulk": { + "title": "Bulk Resolution", + "description": "Apply a resolution strategy to all conflicts at once", + "autoResolveLabel": "Auto-resolve:", + "autoResolveOptions": { + "none": "Disabled", + "local": "Always Local", + "remote": "Always Remote", + "merge": "Always Merge" + } + }, + "filters": { + "label": "Filter by type:", + "allTypes": "All Types", + "files": "Files", + "models": "Models", + "components": "Components", + "workflows": "Workflows", + "lambdas": "Lambdas", + "componentTrees": "Component Trees" + }, + "badges": { + "conflictCount": "{count} {label}" + }, + "emptyStates": { + "filtered": "No conflicts found for this filter", + "noConflictsTitle": "No Conflicts Detected", + "noConflictsDescription": "Your local and remote data are in sync" + }, + "labels": { + "conflictSingular": "conflict", + "conflictPlural": "conflicts" + }, + "toasts": { + "noConflictsDetected": "No conflicts detected", + "foundConflicts": "Found {count} {label}", + "detectFailed": "Failed to detect conflicts", + "resolved": "Conflict resolved using {strategy} version", + "resolveFailed": "Failed to resolve conflict", + "resolvedAll": "All conflicts resolved using {strategy} version", + "resolveAllFailed": "Failed to resolve all conflicts" + }, + "error": { + "title": "Error" + } +}