Merge pull request #47 from johndoe6345789/codex/refactor-conflict-components-into-smaller-pieces

Refactor conflict resolution UI: split components and externalize copy
This commit is contained in:
2026-01-18 00:37:21 +00:00
committed by GitHub
4 changed files with 728 additions and 488 deletions

View File

@@ -1,14 +1,13 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { ConflictItem } 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 {
ArrowsLeftRight,
CheckCircle,
Clock,
import conflictCopy from '@/data/conflict-resolution-copy.json'
import {
ArrowsLeftRight,
Clock,
Database,
Cloud,
Code,
@@ -26,24 +25,160 @@ interface ConflictCardProps {
isResolving: boolean
}
const cardCopy = conflictCopy.card
interface ConflictCardHeaderProps {
conflict: ConflictItem
expanded: boolean
onToggle: () => void
timeDiffMinutes: number
}
interface ConflictVersionPanelProps {
label: string
timestamp: number
version: Record<string, unknown>
icon: ReactNode
highlightBadge?: boolean
accentClassName?: string
}
interface ConflictCardActionsProps {
conflict: ConflictItem
onResolve: (conflictId: string, strategy: 'local' | 'remote' | 'merge') => void
onViewDetails: (conflict: ConflictItem) => void
isResolving: boolean
}
function getEntityIcon(entityType: string) {
switch (entityType) {
case 'files':
return <Code size={20} weight="duotone" className="text-primary" />
case 'models':
return <Database size={20} weight="duotone" className="text-accent" />
default:
return <Database size={20} weight="duotone" className="text-muted-foreground" />
}
}
function ConflictCardHeader({ conflict, expanded, onToggle, timeDiffMinutes }: ConflictCardHeaderProps) {
return (
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5">{getEntityIcon(conflict.entityType)}</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-mono truncate">
{conflict.id}
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{conflict.entityType}
</Badge>
<span className="text-xs text-muted-foreground">
{timeDiffMinutes}
{cardCopy.timeDifferenceSuffix}
</span>
</CardDescription>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onToggle}
>
{expanded ? <CaretDown size={16} /> : <CaretRight size={16} />}
</Button>
</div>
</CardHeader>
)
}
function ConflictVersionPanel({
label,
timestamp,
version,
icon,
highlightBadge,
accentClassName,
}: ConflictVersionPanelProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
{icon}
<h4 className="text-sm font-medium">{label}</h4>
{highlightBadge && (
<Badge variant="secondary" className="text-xs">
{cardCopy.newerLabel}
</Badge>
)}
</div>
<div className="bg-muted/50 rounded-md p-3 space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(timestamp), 'MMM d, h:mm a')}
</div>
<pre className={`text-xs overflow-hidden text-ellipsis ${accentClassName ?? ''}`}>
{JSON.stringify(version, null, 2).slice(0, 200)}...
</pre>
</div>
</div>
)
}
function ConflictCardActions({ conflict, onResolve, onViewDetails, isResolving }: ConflictCardActionsProps) {
return (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'local')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<Database size={16} />
{cardCopy.keepLocal}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'remote')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<Cloud size={16} />
{cardCopy.keepRemote}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'merge')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<ArrowsLeftRight size={16} />
{cardCopy.mergeBoth}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => onViewDetails(conflict)}
disabled={isResolving}
>
<MagnifyingGlass size={16} />
{cardCopy.details}
</Button>
</div>
)
}
export function ConflictCard({ conflict, onResolve, onViewDetails, isResolving }: ConflictCardProps) {
const [expanded, setExpanded] = useState(false)
const isLocalNewer = conflict.localTimestamp > conflict.remoteTimestamp
const timeDiff = Math.abs(conflict.localTimestamp - conflict.remoteTimestamp)
const timeDiffMinutes = Math.round(timeDiff / 1000 / 60)
const getEntityIcon = () => {
switch (conflict.entityType) {
case 'files':
return <Code size={20} weight="duotone" className="text-primary" />
case 'models':
return <Database size={20} weight="duotone" className="text-accent" />
default:
return <Database size={20} weight="duotone" className="text-muted-foreground" />
}
}
return (
<motion.div
layout
@@ -53,33 +188,12 @@ export function ConflictCard({ conflict, onResolve, onViewDetails, isResolving }
transition={{ duration: 0.2 }}
>
<Card className="border-destructive/30 hover:border-destructive/50 transition-colors">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5">{getEntityIcon()}</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-mono truncate">
{conflict.id}
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{conflict.entityType}
</Badge>
<span className="text-xs text-muted-foreground">
{timeDiffMinutes}m difference
</span>
</CardDescription>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <CaretDown size={16} /> : <CaretRight size={16} />}
</Button>
</div>
</CardHeader>
<ConflictCardHeader
conflict={conflict}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
timeDiffMinutes={timeDiffMinutes}
/>
<AnimatePresence>
{expanded && (
@@ -93,92 +207,31 @@ export function ConflictCard({ conflict, onResolve, onViewDetails, isResolving }
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Database size={16} className="text-primary" />
<h4 className="text-sm font-medium">Local Version</h4>
{isLocalNewer && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
<div className="bg-muted/50 rounded-md p-3 space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.localTimestamp), 'MMM d, h:mm a')}
</div>
<pre className="text-xs overflow-hidden text-ellipsis">
{JSON.stringify(conflict.localVersion, null, 2).slice(0, 200)}...
</pre>
</div>
</div>
<ConflictVersionPanel
label={cardCopy.localVersionLabel}
timestamp={conflict.localTimestamp}
version={conflict.localVersion}
icon={<Database size={16} className="text-primary" />}
highlightBadge={isLocalNewer}
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cloud size={16} className="text-accent" />
<h4 className="text-sm font-medium">Remote Version</h4>
{!isLocalNewer && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
<div className="bg-muted/50 rounded-md p-3 space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.remoteTimestamp), 'MMM d, h:mm a')}
</div>
<pre className="text-xs overflow-hidden text-ellipsis">
{JSON.stringify(conflict.remoteVersion, null, 2).slice(0, 200)}...
</pre>
</div>
</div>
<ConflictVersionPanel
label={cardCopy.remoteVersionLabel}
timestamp={conflict.remoteTimestamp}
version={conflict.remoteVersion}
icon={<Cloud size={16} className="text-accent" />}
highlightBadge={!isLocalNewer}
/>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'local')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<Database size={16} />
Keep Local
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'remote')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<Cloud size={16} />
Keep Remote
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve(conflict.id, 'merge')}
disabled={isResolving}
className="flex-1 min-w-[120px]"
>
<ArrowsLeftRight size={16} />
Merge Both
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => onViewDetails(conflict)}
disabled={isResolving}
>
<MagnifyingGlass size={16} />
Details
</Button>
</div>
<ConflictCardActions
conflict={conflict}
onResolve={onResolve}
onViewDetails={onViewDetails}
isResolving={isResolving}
/>
</CardContent>
</motion.div>
)}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { ConflictItem } from '@/types/conflicts'
import {
Dialog,
@@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import conflictCopy from '@/data/conflict-resolution-copy.json'
import { Database, Cloud, ArrowsLeftRight, Clock, CheckCircle } from '@phosphor-icons/react'
import { format } from 'date-fns'
@@ -23,6 +24,226 @@ interface ConflictDetailsDialogProps {
isResolving: boolean
}
type ConflictTab = 'local' | 'remote' | 'diff'
type ConflictDiffItem = {
key: string
localValue: unknown
remoteValue: unknown
isDifferent: boolean
onlyInLocal: boolean
onlyInRemote: boolean
}
const dialogCopy = conflictCopy.detailsDialog
function getConflictDiff(conflict: ConflictItem): ConflictDiffItem[] {
const localKeys = Object.keys(conflict.localVersion)
const remoteKeys = Object.keys(conflict.remoteVersion)
const allKeys = Array.from(new Set([...localKeys, ...remoteKeys]))
return allKeys.map((key) => {
const localValue = conflict.localVersion[key]
const remoteValue = conflict.remoteVersion[key]
const isDifferent = JSON.stringify(localValue) !== JSON.stringify(remoteValue)
const onlyInLocal = !(key in conflict.remoteVersion)
const onlyInRemote = !(key in conflict.localVersion)
return {
key,
localValue,
remoteValue,
isDifferent,
onlyInLocal,
onlyInRemote,
}
})
}
function ConflictDetailsHeader({ conflict }: { conflict: ConflictItem }) {
return (
<DialogHeader>
<DialogTitle className="font-mono text-lg">{dialogCopy.title}</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<Badge variant="outline">{conflict.entityType}</Badge>
<span>{conflict.id}</span>
</DialogDescription>
</DialogHeader>
)
}
function ConflictVersionSummary({ conflict, isLocalNewer }: { conflict: ConflictItem; isLocalNewer: boolean }) {
return (
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex items-center gap-2">
<Database size={20} className="text-primary" />
<div className="flex-1">
<div className="text-sm font-medium">{dialogCopy.localVersionLabel}</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.localTimestamp), 'PPp')}
</div>
</div>
{isLocalNewer && (
<Badge variant="secondary" className="text-xs">
{dialogCopy.newerLabel}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Cloud size={20} className="text-accent" />
<div className="flex-1">
<div className="text-sm font-medium">{dialogCopy.remoteVersionLabel}</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.remoteTimestamp), 'PPp')}
</div>
</div>
{!isLocalNewer && (
<Badge variant="secondary" className="text-xs">
{dialogCopy.newerLabel}
</Badge>
)}
</div>
</div>
)
}
function DiffItemCard({ item }: { item: ConflictDiffItem }) {
return (
<div
key={item.key}
className={`p-3 rounded-md border ${
item.isDifferent
? 'border-destructive/30 bg-destructive/5'
: 'border-border bg-muted/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-sm font-medium">{item.key}</span>
{item.isDifferent && (
<Badge variant="destructive" className="text-xs">
{dialogCopy.conflictBadge}
</Badge>
)}
{!item.isDifferent && (
<Badge variant="secondary" className="text-xs">
<CheckCircle size={12} />
{dialogCopy.matchBadge}
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3 text-xs font-mono">
<div>
<div className="text-muted-foreground mb-1">{dialogCopy.localFieldLabel}</div>
<div className={item.onlyInLocal ? 'text-primary font-medium' : ''}>
{item.onlyInLocal ? (
<Badge variant="outline" className="text-xs">{dialogCopy.onlyInLocal}</Badge>
) : (
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(item.localValue, null, 2)}
</pre>
)}
</div>
</div>
<div>
<div className="text-muted-foreground mb-1">{dialogCopy.remoteFieldLabel}</div>
<div className={item.onlyInRemote ? 'text-accent font-medium' : ''}>
{item.onlyInRemote ? (
<Badge variant="outline" className="text-xs">{dialogCopy.onlyInRemote}</Badge>
) : (
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(item.remoteValue, null, 2)}
</pre>
)}
</div>
</div>
</div>
</div>
)
}
function DiffTabContent({ diff }: { diff: ConflictDiffItem[] }) {
return (
<TabsContent value="diff" className="flex-1 min-h-0">
<ScrollArea className="h-[400px] rounded-md border">
<div className="p-4 space-y-2">
{diff.map((item) => (
<DiffItemCard key={item.key} item={item} />
))}
</div>
</ScrollArea>
</TabsContent>
)
}
function JsonTabContent({ value, json }: { value: ConflictTab; json: string }) {
return (
<TabsContent value={value} className="flex-1 min-h-0">
<ScrollArea className="h-[400px] rounded-md border">
<pre className="p-4 text-xs font-mono">{json}</pre>
</ScrollArea>
</TabsContent>
)
}
function ResolutionFooter({
conflict,
isResolving,
onOpenChange,
onResolve,
}: {
conflict: ConflictItem
isResolving: boolean
onOpenChange: (open: boolean) => void
onResolve: (conflictId: string, strategy: 'local' | 'remote' | 'merge') => void
}) {
return (
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{dialogCopy.cancel}
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
onResolve(conflict.id, 'local')
onOpenChange(false)
}}
disabled={isResolving}
>
<Database size={16} />
{dialogCopy.keepLocal}
</Button>
<Button
variant="outline"
onClick={() => {
onResolve(conflict.id, 'remote')
onOpenChange(false)
}}
disabled={isResolving}
>
<Cloud size={16} />
{dialogCopy.keepRemote}
</Button>
<Button
onClick={() => {
onResolve(conflict.id, 'merge')
onOpenChange(false)
}}
disabled={isResolving}
>
<ArrowsLeftRight size={16} />
{dialogCopy.mergeBoth}
</Button>
</div>
</div>
)
}
export function ConflictDetailsDialog({
conflict,
open,
@@ -30,7 +251,7 @@ export function ConflictDetailsDialog({
onResolve,
isResolving,
}: ConflictDetailsDialogProps) {
const [activeTab, setActiveTab] = useState<'local' | 'remote' | 'diff'>('diff')
const [activeTab, setActiveTab] = useState<ConflictTab>('diff')
if (!conflict) return null
@@ -38,209 +259,49 @@ export function ConflictDetailsDialog({
const localJson = JSON.stringify(conflict.localVersion, null, 2)
const remoteJson = JSON.stringify(conflict.remoteVersion, null, 2)
const getDiff = () => {
const localKeys = Object.keys(conflict.localVersion)
const remoteKeys = Object.keys(conflict.remoteVersion)
const allKeys = Array.from(new Set([...localKeys, ...remoteKeys]))
return allKeys.map((key) => {
const localValue = conflict.localVersion[key]
const remoteValue = conflict.remoteVersion[key]
const isDifferent = JSON.stringify(localValue) !== JSON.stringify(remoteValue)
const onlyInLocal = !(key in conflict.remoteVersion)
const onlyInRemote = !(key in conflict.localVersion)
return {
key,
localValue,
remoteValue,
isDifferent,
onlyInLocal,
onlyInRemote,
}
})
}
const diff = getDiff()
const conflictingKeys = diff.filter((d) => d.isDifferent)
const diff = useMemo(() => getConflictDiff(conflict), [conflict])
const conflictingKeys = diff.filter((item) => item.isDifferent)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="font-mono text-lg">Conflict Details</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<Badge variant="outline">{conflict.entityType}</Badge>
<span>{conflict.id}</span>
</DialogDescription>
</DialogHeader>
<ConflictDetailsHeader conflict={conflict} />
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex items-center gap-2">
<Database size={20} className="text-primary" />
<div className="flex-1">
<div className="text-sm font-medium">Local Version</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.localTimestamp), 'PPp')}
</div>
</div>
{isLocalNewer && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Cloud size={20} className="text-accent" />
<div className="flex-1">
<div className="text-sm font-medium">Remote Version</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock size={12} />
{format(new Date(conflict.remoteTimestamp), 'PPp')}
</div>
</div>
{!isLocalNewer && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
</div>
<ConflictVersionSummary conflict={conflict} isLocalNewer={isLocalNewer} />
<Separator />
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConflictTab)} className="flex-1 flex flex-col min-h-0">
<TabsList className="grid grid-cols-3 w-full">
<TabsTrigger value="diff" className="gap-2">
<ArrowsLeftRight size={16} />
Differences ({conflictingKeys.length})
{dialogCopy.differencesLabel} ({conflictingKeys.length})
</TabsTrigger>
<TabsTrigger value="local" className="gap-2">
<Database size={16} />
Local
{dialogCopy.localTabLabel}
</TabsTrigger>
<TabsTrigger value="remote" className="gap-2">
<Cloud size={16} />
Remote
{dialogCopy.remoteTabLabel}
</TabsTrigger>
</TabsList>
<TabsContent value="diff" className="flex-1 min-h-0">
<ScrollArea className="h-[400px] rounded-md border">
<div className="p-4 space-y-2">
{diff.map((item) => (
<div
key={item.key}
className={`p-3 rounded-md border ${
item.isDifferent
? 'border-destructive/30 bg-destructive/5'
: 'border-border bg-muted/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-sm font-medium">{item.key}</span>
{item.isDifferent && (
<Badge variant="destructive" className="text-xs">
Conflict
</Badge>
)}
{!item.isDifferent && (
<Badge variant="secondary" className="text-xs">
<CheckCircle size={12} />
Match
</Badge>
)}
</div>
<DiffTabContent diff={diff} />
<div className="grid grid-cols-2 gap-3 text-xs font-mono">
<div>
<div className="text-muted-foreground mb-1">Local:</div>
<div className={item.onlyInLocal ? 'text-primary font-medium' : ''}>
{item.onlyInLocal ? (
<Badge variant="outline" className="text-xs">Only in local</Badge>
) : (
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(item.localValue, null, 2)}
</pre>
)}
</div>
</div>
<JsonTabContent value="local" json={localJson} />
<div>
<div className="text-muted-foreground mb-1">Remote:</div>
<div className={item.onlyInRemote ? 'text-accent font-medium' : ''}>
{item.onlyInRemote ? (
<Badge variant="outline" className="text-xs">Only in remote</Badge>
) : (
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(item.remoteValue, null, 2)}
</pre>
)}
</div>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="local" className="flex-1 min-h-0">
<ScrollArea className="h-[400px] rounded-md border">
<pre className="p-4 text-xs font-mono">{localJson}</pre>
</ScrollArea>
</TabsContent>
<TabsContent value="remote" className="flex-1 min-h-0">
<ScrollArea className="h-[400px] rounded-md border">
<pre className="p-4 text-xs font-mono">{remoteJson}</pre>
</ScrollArea>
</TabsContent>
<JsonTabContent value="remote" json={remoteJson} />
</Tabs>
<Separator />
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
onResolve(conflict.id, 'local')
onOpenChange(false)
}}
disabled={isResolving}
>
<Database size={16} />
Keep Local
</Button>
<Button
variant="outline"
onClick={() => {
onResolve(conflict.id, 'remote')
onOpenChange(false)
}}
disabled={isResolving}
>
<Cloud size={16} />
Keep Remote
</Button>
<Button
onClick={() => {
onResolve(conflict.id, 'merge')
onOpenChange(false)
}}
disabled={isResolving}
>
<ArrowsLeftRight size={16} />
Merge Both
</Button>
</div>
</div>
<ResolutionFooter
conflict={conflict}
isResolving={isResolving}
onOpenChange={onOpenChange}
onResolve={onResolve}
/>
</DialogContent>
</Dialog>
)

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -6,19 +6,233 @@ import { Separator } from '@/components/ui/separator'
import { useConflictResolution } from '@/hooks/use-conflict-resolution'
import { ConflictIndicator } from '@/components/ConflictIndicator'
import { db } from '@/lib/db'
import {
Flask,
Database,
Warning,
CheckCircle,
ArrowsClockwise
import conflictCopy from '@/data/conflict-resolution-copy.json'
import {
Flask,
Database,
Warning,
CheckCircle,
ArrowsClockwise,
} from '@phosphor-icons/react'
import { toast } from 'sonner'
const demoCopy = conflictCopy.demo
function DemoHeader() {
return (
<div>
<h2 className="text-2xl font-bold font-mono mb-2">{demoCopy.title}</h2>
<p className="text-muted-foreground">{demoCopy.subtitle}</p>
</div>
)
}
function StatusCard({ hasConflicts, stats }: { hasConflicts: boolean; stats: { totalConflicts: number; conflictsByType: Record<string, number> } }) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database size={20} className="text-primary" weight="duotone" />
{demoCopy.statusTitle}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{demoCopy.conflictsLabel}</span>
{hasConflicts ? (
<Badge variant="destructive">{stats.totalConflicts}</Badge>
) : (
<Badge variant="secondary" className="gap-1">
<CheckCircle size={12} />
{demoCopy.noneLabel}
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{demoCopy.filesLabel}</span>
<span className="text-sm font-medium">{stats.conflictsByType.files || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{demoCopy.modelsLabel}</span>
<span className="text-sm font-medium">{stats.conflictsByType.models || 0}</span>
</div>
</CardContent>
</Card>
)
}
function DemoActionsCard({
hasConflicts,
simulatingConflict,
onSimulate,
onDetect,
onResolveAll,
conflictSummary,
}: {
hasConflicts: boolean
simulatingConflict: boolean
onSimulate: () => void
onDetect: () => void
onResolveAll: () => void
conflictSummary: string
}) {
return (
<Card className="md:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Flask size={20} className="text-accent" weight="duotone" />
{demoCopy.demoActionsTitle}
</CardTitle>
<CardDescription>{demoCopy.demoActionsDescription}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={onSimulate}
disabled={simulatingConflict}
>
<Warning size={16} />
{demoCopy.simulateConflict}
</Button>
<Button
size="sm"
variant="outline"
onClick={onDetect}
>
<ArrowsClockwise size={16} />
{demoCopy.detectConflicts}
</Button>
{hasConflicts && (
<>
<Separator orientation="vertical" className="h-8" />
<Button
size="sm"
variant="destructive"
onClick={onResolveAll}
>
<CheckCircle size={16} />
{demoCopy.resolveAllLocal}
</Button>
</>
)}
</div>
{hasConflicts && (
<div className="mt-4 p-3 bg-destructive/10 rounded-md border border-destructive/30">
<div className="flex items-start gap-2">
<Warning size={20} className="text-destructive mt-0.5" weight="fill" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
{conflictSummary}
</p>
<p className="text-xs text-muted-foreground mt-1">
{demoCopy.navigateMessage}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)
}
function ResolutionStrategiesCard() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{demoCopy.resolutionStrategiesTitle}</CardTitle>
<CardDescription>{demoCopy.resolutionStrategiesDescription}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<Database size={16} className="text-primary" />
<span className="font-medium text-sm">{demoCopy.strategyKeepLocalTitle}</span>
</div>
<p className="text-xs text-muted-foreground">
{demoCopy.strategyKeepLocalDescription}
</p>
</div>
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<Flask size={16} className="text-accent" />
<span className="font-medium text-sm">{demoCopy.strategyKeepRemoteTitle}</span>
</div>
<p className="text-xs text-muted-foreground">
{demoCopy.strategyKeepRemoteDescription}
</p>
</div>
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<ArrowsClockwise size={16} className="text-primary" />
<span className="font-medium text-sm">{demoCopy.strategyMergeBothTitle}</span>
</div>
<p className="text-xs text-muted-foreground">
{demoCopy.strategyMergeBothDescription}
</p>
</div>
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<CheckCircle size={16} className="text-accent" />
<span className="font-medium text-sm">{demoCopy.strategyManualEditTitle}</span>
</div>
<p className="text-xs text-muted-foreground">
{demoCopy.strategyManualEditDescription}
</p>
</div>
</div>
</CardContent>
</Card>
)
}
function ConflictIndicatorCard() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ConflictIndicator variant="compact" showLabel={false} />
{demoCopy.indicatorTitle}
</CardTitle>
<CardDescription>
{demoCopy.indicatorDescription}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{demoCopy.badgeVariantLabel}</span>
<ConflictIndicator variant="badge" showLabel={true} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{demoCopy.compactVariantLabel}</span>
<ConflictIndicator variant="compact" />
</div>
</div>
</CardContent>
</Card>
)
}
export function ConflictResolutionDemo() {
const { hasConflicts, stats, detect, resolveAll } = useConflictResolution()
const [simulatingConflict, setSimulatingConflict] = useState(false)
const conflictSummary = useMemo(() => {
const count = stats.totalConflicts
const label = count === 1 ? demoCopy.conflictSingular : demoCopy.conflictPlural
return `${count} ${label} ${demoCopy.detectedSuffix}`
}, [stats.totalConflicts])
const simulateConflict = async () => {
setSimulatingConflict(true)
try {
@@ -32,14 +246,14 @@ export function ConflictResolutionDemo() {
}
await db.put('files', testFile)
toast.info('Simulated local file created. Now simulating a remote conflict...')
toast.info(demoCopy.toastLocalCreated)
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('Conflict simulation complete! Click "Detect Conflicts" to see it.')
toast.success(demoCopy.toastSimulationComplete)
} catch (err: any) {
toast.error(err.message || 'Failed to simulate conflict')
toast.error(err.message || demoCopy.toastSimulationError)
} finally {
setSimulatingConflict(false)
}
@@ -48,188 +262,32 @@ export function ConflictResolutionDemo() {
const handleQuickResolveAll = async () => {
try {
await resolveAll('local')
toast.success('All conflicts resolved using local versions')
toast.success(demoCopy.toastResolveAllSuccess)
} catch (err: any) {
toast.error(err.message || 'Failed to resolve conflicts')
toast.error(err.message || demoCopy.toastResolveAllError)
}
}
return (
<div className="space-y-6 p-6">
<div>
<h2 className="text-2xl font-bold font-mono mb-2">Conflict Resolution System</h2>
<p className="text-muted-foreground">
Demo and test the conflict detection and resolution features
</p>
</div>
<DemoHeader />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database size={20} className="text-primary" weight="duotone" />
Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Conflicts:</span>
{hasConflicts ? (
<Badge variant="destructive">{stats.totalConflicts}</Badge>
) : (
<Badge variant="secondary" className="gap-1">
<CheckCircle size={12} />
None
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Files:</span>
<span className="text-sm font-medium">{stats.conflictsByType.files || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Models:</span>
<span className="text-sm font-medium">{stats.conflictsByType.models || 0}</span>
</div>
</CardContent>
</Card>
<StatusCard hasConflicts={hasConflicts} stats={stats} />
<Card className="md:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Flask size={20} className="text-accent" weight="duotone" />
Demo Actions
</CardTitle>
<CardDescription>Test the conflict resolution workflow</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={simulateConflict}
disabled={simulatingConflict}
>
<Warning size={16} />
Simulate Conflict
</Button>
<Button
size="sm"
variant="outline"
onClick={() => detect()}
>
<ArrowsClockwise size={16} />
Detect Conflicts
</Button>
{hasConflicts && (
<>
<Separator orientation="vertical" className="h-8" />
<Button
size="sm"
variant="destructive"
onClick={handleQuickResolveAll}
>
<CheckCircle size={16} />
Resolve All (Local)
</Button>
</>
)}
</div>
{hasConflicts && (
<div className="mt-4 p-3 bg-destructive/10 rounded-md border border-destructive/30">
<div className="flex items-start gap-2">
<Warning size={20} className="text-destructive mt-0.5" weight="fill" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
{stats.totalConflicts} conflict{stats.totalConflicts === 1 ? '' : 's'} detected
</p>
<p className="text-xs text-muted-foreground mt-1">
Navigate to the Conflict Resolution page to review and resolve them
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<DemoActionsCard
hasConflicts={hasConflicts}
simulatingConflict={simulatingConflict}
onSimulate={simulateConflict}
onDetect={detect}
onResolveAll={handleQuickResolveAll}
conflictSummary={conflictSummary}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Resolution Strategies</CardTitle>
<CardDescription>Available approaches for handling conflicts</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<Database size={16} className="text-primary" />
<span className="font-medium text-sm">Keep Local</span>
</div>
<p className="text-xs text-muted-foreground">
Preserve the local version and discard remote changes
</p>
</div>
<ResolutionStrategiesCard />
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<Flask size={16} className="text-accent" />
<span className="font-medium text-sm">Keep Remote</span>
</div>
<p className="text-xs text-muted-foreground">
Accept the remote version and overwrite local changes
</p>
</div>
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<ArrowsClockwise size={16} className="text-primary" />
<span className="font-medium text-sm">Merge Both</span>
</div>
<p className="text-xs text-muted-foreground">
Combine local and remote changes into a single version
</p>
</div>
<div className="p-3 rounded-md border bg-card">
<div className="flex items-center gap-2 mb-1">
<CheckCircle size={16} className="text-accent" />
<span className="font-medium text-sm">Manual Edit</span>
</div>
<p className="text-xs text-muted-foreground">
Manually edit the conflicting data to create a custom resolution
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ConflictIndicator variant="compact" showLabel={false} />
Conflict Indicator Component
</CardTitle>
<CardDescription>
The conflict indicator can be placed anywhere in the UI to show active conflicts
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Badge variant:</span>
<ConflictIndicator variant="badge" showLabel={true} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Compact variant:</span>
<ConflictIndicator variant="compact" />
</div>
</div>
</CardContent>
</Card>
<ConflictIndicatorCard />
</div>
)
}

View File

@@ -0,0 +1,68 @@
{
"card": {
"timeDifferenceSuffix": "m difference",
"localVersionLabel": "Local Version",
"remoteVersionLabel": "Remote Version",
"newerLabel": "Newer",
"keepLocal": "Keep Local",
"keepRemote": "Keep Remote",
"mergeBoth": "Merge Both",
"details": "Details"
},
"detailsDialog": {
"title": "Conflict Details",
"localVersionLabel": "Local Version",
"remoteVersionLabel": "Remote Version",
"newerLabel": "Newer",
"differencesLabel": "Differences",
"localTabLabel": "Local",
"remoteTabLabel": "Remote",
"conflictBadge": "Conflict",
"matchBadge": "Match",
"localFieldLabel": "Local:",
"remoteFieldLabel": "Remote:",
"onlyInLocal": "Only in local",
"onlyInRemote": "Only in remote",
"cancel": "Cancel",
"keepLocal": "Keep Local",
"keepRemote": "Keep Remote",
"mergeBoth": "Merge Both"
},
"demo": {
"title": "Conflict Resolution System",
"subtitle": "Demo and test the conflict detection and resolution features",
"statusTitle": "Status",
"conflictsLabel": "Conflicts:",
"filesLabel": "Files:",
"modelsLabel": "Models:",
"noneLabel": "None",
"demoActionsTitle": "Demo Actions",
"demoActionsDescription": "Test the conflict resolution workflow",
"simulateConflict": "Simulate Conflict",
"detectConflicts": "Detect Conflicts",
"resolveAllLocal": "Resolve All (Local)",
"conflictSingular": "conflict",
"conflictPlural": "conflicts",
"detectedSuffix": "detected",
"navigateMessage": "Navigate to the Conflict Resolution page to review and resolve them",
"resolutionStrategiesTitle": "Resolution Strategies",
"resolutionStrategiesDescription": "Available approaches for handling conflicts",
"strategyKeepLocalTitle": "Keep Local",
"strategyKeepLocalDescription": "Preserve the local version and discard remote changes",
"strategyKeepRemoteTitle": "Keep Remote",
"strategyKeepRemoteDescription": "Accept the remote version and overwrite local changes",
"strategyMergeBothTitle": "Merge Both",
"strategyMergeBothDescription": "Combine local and remote changes into a single version",
"strategyManualEditTitle": "Manual Edit",
"strategyManualEditDescription": "Manually edit the conflicting data to create a custom resolution",
"indicatorTitle": "Conflict Indicator Component",
"indicatorDescription": "The conflict indicator can be placed anywhere in the UI to show active conflicts",
"badgeVariantLabel": "Badge variant:",
"compactVariantLabel": "Compact variant:",
"toastLocalCreated": "Simulated local file created. Now simulating a remote conflict...",
"toastSimulationComplete": "Conflict simulation complete! Click \"Detect Conflicts\" to see it.",
"toastSimulationError": "Failed to simulate conflict",
"toastResolveAllSuccess": "All conflicts resolved using local versions",
"toastResolveAllError": "Failed to resolve conflicts"
}
}