Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/components/ConflictResolver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
'use client';

import React, { useState } from 'react';
import { ConflictRecord, ResolutionStrategy } from '@/lib/conflict/types';
import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertTriangle, ArrowRight, Save, History, Check } from 'lucide-react';

interface ConflictResolverProps {
conflict: ConflictRecord<any>;
onResolve: (strategy: ResolutionStrategy, manualData?: any) => void;
onClose: () => void;
}

export const ConflictResolver: React.FC<ConflictResolverProps> = ({
conflict,
onResolve,
onClose,
}) => {
const [selectedStrategy, setSelectedStrategy] = useState<ResolutionStrategy>('manual');
const [showHistory, setShowHistory] = useState(false);

const localItems = Object.entries(conflict.localData).filter(
([key]) => !['updatedAt', 'version', 'id'].includes(key),
);

const remoteItems = Object.entries(conflict.remoteData).filter(
([key]) => !['updatedAt', 'version', 'id'].includes(key),
);

return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-[#1a1c1e] border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col"
>
{/* Header */}
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-gradient-to-r from-orange-500/10 to-transparent">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center text-orange-500">
<AlertTriangle size={24} />
</div>
<div>
<h3 className="text-xl font-bold text-white">Conflict Detected</h3>
<p className="text-sm text-gray-400">
Resolution required for {conflict.entityType}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
>
<X size={20} />
</button>
</div>

{/* Content */}
<div className="p-6 overflow-y-auto max-h-[60vh] space-y-6">
<div className="grid grid-cols-2 gap-4">
{/* Local Changes */}
<div
onClick={() => setSelectedStrategy('local')}
className={`p-4 rounded-xl border-2 transition-all cursor-pointer ${
selectedStrategy === 'local'
? 'border-orange-500 bg-orange-500/5'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold uppercase tracking-wider text-orange-500">
Local Version
</span>
{selectedStrategy === 'local' && <Check size={16} className="text-orange-500" />}
</div>
<div className="space-y-2">
{localItems.map(([key, value]) => (
<div key={key} className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase">{key}</span>
<span className="text-sm text-gray-200">{String(value)}</span>
</div>
))}
</div>
</div>

{/* Remote Changes */}
<div
onClick={() => setSelectedStrategy('remote')}
className={`p-4 rounded-xl border-2 transition-all cursor-pointer ${
selectedStrategy === 'remote'
? 'border-blue-500 bg-blue-500/5'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold uppercase tracking-wider text-blue-500">
Remote Version
</span>
{selectedStrategy === 'remote' && <Check size={16} className="text-blue-500" />}
</div>
<div className="space-y-2">
{remoteItems.map(([key, value]) => (
<div key={key} className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase">{key}</span>
<span className="text-sm text-gray-200">{String(value)}</span>
</div>
))}
</div>
</div>
</div>

{/* Merge Option */}
<div
onClick={() => setSelectedStrategy('merge')}
className={`p-4 rounded-xl border-2 transition-all cursor-pointer flex items-center justify-between ${
selectedStrategy === 'merge'
? 'border-purple-500 bg-purple-500/5'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-500/20 text-purple-500">
<ArrowRight size={18} />
</div>
<div>
<span className="block text-sm font-bold text-white">Smart Merge</span>
<span className="text-xs text-gray-400">Combine changes automatically</span>
</div>
</div>
{selectedStrategy === 'merge' && <Check size={18} className="text-purple-500" />}
</div>

{/* History Toggle */}
<button
onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 text-xs text-gray-500 hover:text-white transition-colors"
>
<History size={14} />
{showHistory ? 'Hide Conflict History' : 'Show Conflict History'}
</button>

{showHistory && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="space-y-2 pl-4 border-l border-white/10"
>
{conflict.history.map((h, i) => (
<div key={i} className="text-[11px]">
<span className="text-gray-500">{new Date(h.timestamp).toLocaleString()}</span>
<span className="mx-2 text-gray-700">•</span>
<span className="text-gray-300 font-medium">{h.action}</span>
<p className="text-gray-500 mt-0.5">{h.details}</p>
</div>
))}
</motion.div>
)}
</div>

{/* Footer */}
<div className="p-6 border-t border-white/5 bg-white/[0.02] flex items-center justify-between">
<span className="text-xs text-gray-500">
Resolved conflicts are tracked in the audit log.
</span>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={() => onResolve(selectedStrategy)}
disabled={selectedStrategy === 'manual'}
className="px-6 py-2 bg-white text-black text-sm font-bold rounded-xl hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<Save size={16} />
Resolve Conflict
</button>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
101 changes: 101 additions & 0 deletions src/lib/conflict/resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { ConflictRecord, ResolutionStrategy, ProgressData } from './types';

/**
* Detects if a conflict exists between local and remote data based on timestamps and versions.
*/
export function detectConflict<T extends { updatedAt: string; version?: number }>(
local: T,
remote: T,
): boolean {
// If remote is newer than local, we have a potential conflict
// (Assuming local changes were made while remote was already newer)
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();

if (remoteTime > localTime) {
return true;
}

if (local.version !== undefined && remote.version !== undefined) {
return local.version < remote.version;
}

return false;
}

/**
* Resolves a conflict based on the chosen strategy.
*/
export function resolveConflict<T>(local: T, remote: T, strategy: ResolutionStrategy): T {
switch (strategy) {
case 'local':
return local;
case 'remote':
return remote;
case 'merge':
return mergeData(local, remote);
case 'manual':
// Manual resolution handled by UI, default to remote if called programmatically
return remote;
default:
return remote;
}
}

/**
* Merges two data objects. Specifically handles ProgressData.
*/
function mergeData<T>(local: T, remote: T): T {
// If it's progress data, we merge by taking the maximum progress and completion status
if (isProgressData(local) && isProgressData(remote)) {
const merged: ProgressData = {
...remote,
progress: Math.max(local.progress, remote.progress),
completed: local.completed || remote.completed,
updatedAt: new Date().toISOString(),
version: Math.max(local.version || 0, remote.version || 0) + 1,
};
return merged as unknown as T;
}

// Generic merge (last write wins on field level if they were objects, but here we just return remote)
return { ...local, ...remote };
}

function isProgressData(data: any): data is ProgressData {
return (
data &&
typeof data.progress === 'number' &&
typeof data.completed === 'boolean' &&
typeof data.updatedAt === 'string'
);
}

/**
* Creates a new conflict record with initial history.
*/
export function createConflictRecord<T>(
entityType: string,
entityKey: string,
localData: T,
remoteData: T,
): ConflictRecord<T> {
const id = `conflict-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return {
id,
entityType,
entityKey,
localData,
remoteData,
timestamp: new Date().toISOString(),
strategy: 'manual',
resolved: false,
history: [
{
timestamp: new Date().toISOString(),
action: 'CREATED',
details: 'Conflict detected during synchronization',
},
],
};
}
27 changes: 27 additions & 0 deletions src/lib/conflict/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type ResolutionStrategy = 'local' | 'remote' | 'merge' | 'manual';

export interface ConflictRecord<T> {
id: string;
entityType: string;
entityKey: string;
localData: T;
remoteData: T;
timestamp: string;
strategy: ResolutionStrategy;
resolved: boolean;
history: Array<{
timestamp: string;
action: string;
details?: string;
}>;
}

export interface SyncMetadata {
updatedAt: string;
version: number;
}

export interface ProgressData extends SyncMetadata {
progress: number;
completed: boolean;
}
Loading
Loading