Compare commits
4 commits
95adee9710
...
8c9b7d84ca
Author | SHA1 | Date | |
---|---|---|---|
8c9b7d84ca | |||
5f5d8f00d9 | |||
afc70a56ba | |||
109377f083 |
4 changed files with 191 additions and 62 deletions
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
132
src/app/[encodedUrl]/page.tsx
Normal file
132
src/app/[encodedUrl]/page.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { ItemsList } from '@/components/ItemsList';
|
||||||
|
|
||||||
|
// Loading component for better UX
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
<span className="text-gray-600 text-sm">Loading items...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error boundary component
|
||||||
|
function ErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md w-full">
|
||||||
|
<h2 className="text-red-800 text-lg font-semibold mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="text-red-600 text-sm mb-4">
|
||||||
|
{error.message || 'An unexpected error occurred while loading the items.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={resetError}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple error boundary implementation
|
||||||
|
class ErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode; fallback: (error: Error, resetError: () => void) => React.ReactNode },
|
||||||
|
{ hasError: boolean; error?: Error }
|
||||||
|
> {
|
||||||
|
constructor(props: { children: React.ReactNode; fallback: (error: Error, resetError: () => void) => React.ReactNode }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError && this.state.error) {
|
||||||
|
return this.props.fallback(this.state.error, () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UrlPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const encodedUrlParam = params.encodedUrl || '';
|
||||||
|
|
||||||
|
// Handle both string and string[] cases
|
||||||
|
const encodedUrl = Array.isArray(encodedUrlParam) ? encodedUrlParam[0] : encodedUrlParam;
|
||||||
|
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-sm border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
|
CompareWare
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Item Comparison Tool
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
{`Items Comparison for "${decodedUrl}"`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items list with error boundary and suspense */}
|
||||||
|
<ErrorBoundary fallback={(error, resetError) => <ErrorFallback error={error} resetError={resetError} />}>
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<ItemsList url={decodedUrl} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white border-t mt-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="text-center text-gray-500 text-sm">
|
||||||
|
<p>© 2025 CompareWare.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -26,65 +26,67 @@ export function EditableCell({
|
||||||
className = ''
|
className = ''
|
||||||
}: EditableCellProps) {
|
}: EditableCellProps) {
|
||||||
const [localValue, setLocalValue] = useState(value);
|
const [localValue, setLocalValue] = useState(value);
|
||||||
const [isExternalUpdate, setIsExternalUpdate] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const lastExternalValueRef = useRef(value);
|
||||||
|
const isUserInputRef = useRef(false);
|
||||||
|
|
||||||
// Update local value when prop changes (including external updates)
|
// Update local value when prop changes (only for genuine external updates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== localValue) {
|
// Only update if this is a genuine external change (not from user input)
|
||||||
|
if (value !== lastExternalValueRef.current && !isUserInputRef.current) {
|
||||||
|
console.log('External value change detected for cell:', cellKey, value);
|
||||||
setLocalValue(value);
|
setLocalValue(value);
|
||||||
setIsExternalUpdate(true);
|
|
||||||
}
|
}
|
||||||
}, [value, localValue]);
|
lastExternalValueRef.current = value;
|
||||||
|
|
||||||
// Reset external update flag after a short delay
|
// Reset user input flag after a short delay
|
||||||
useEffect(() => {
|
if (isUserInputRef.current) {
|
||||||
if (isExternalUpdate) {
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setIsExternalUpdate(false);
|
isUserInputRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isExternalUpdate]);
|
}, [value, cellKey]);
|
||||||
|
|
||||||
// Handle focus when this cell becomes focused
|
// Handle focus when this cell becomes focused
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedCell === cellKey) {
|
if (focusedCell === cellKey) {
|
||||||
const element = inputType === 'textarea' ? textareaRef.current : inputRef.current;
|
const element = inputType === 'textarea' ? textareaRef.current : inputRef.current;
|
||||||
if (element) {
|
if (element && document.activeElement !== element) {
|
||||||
element.focus();
|
element.focus();
|
||||||
// Select all text when focusing, but not during external updates
|
// Only select text if this is not from user input
|
||||||
if (!isExternalUpdate) {
|
if (!isUserInputRef.current) {
|
||||||
element.select();
|
element.select();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [focusedCell, cellKey, inputType, isExternalUpdate]);
|
}, [focusedCell, cellKey, inputType]);
|
||||||
|
|
||||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
console.log('Input event:', newValue);
|
console.log('Input event for cell:', cellKey, newValue);
|
||||||
|
isUserInputRef.current = true; // Mark as user input
|
||||||
setLocalValue(newValue);
|
setLocalValue(newValue);
|
||||||
}, []);
|
}, [cellKey]);
|
||||||
|
|
||||||
const commitInput = useCallback(() => {
|
const commitInput = useCallback(() => {
|
||||||
console.log('Committing input:', localValue);
|
console.log('Committing input for cell:', cellKey, localValue);
|
||||||
onInput(localValue);
|
onInput(localValue);
|
||||||
}, [localValue, onInput]);
|
}, [localValue, onInput, cellKey]);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
console.log('Focus gained for key:', cellKey);
|
console.log('Focus gained for cell:', cellKey);
|
||||||
setFocusedCell(cellKey);
|
setFocusedCell(cellKey);
|
||||||
onFocus?.();
|
onFocus?.();
|
||||||
}, [cellKey, setFocusedCell, onFocus]);
|
}, [cellKey, setFocusedCell, onFocus]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
console.log('Focus lost');
|
console.log('Focus lost for cell:', cellKey);
|
||||||
setFocusedCell(null);
|
setFocusedCell(null);
|
||||||
commitInput();
|
commitInput();
|
||||||
onBlur?.();
|
onBlur?.();
|
||||||
}, [setFocusedCell, commitInput, onBlur]);
|
}, [setFocusedCell, commitInput, onBlur, cellKey]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && inputType === 'text') {
|
if (e.key === 'Enter' && inputType === 'text') {
|
||||||
|
|
|
@ -274,13 +274,14 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
setPropertyLabels(prev => ({ ...prev, ...labels }));
|
setPropertyLabels(prev => ({ ...prev, ...labels }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, [itemsData]);
|
||||||
|
|
||||||
// Set initial focus
|
// Reset focus state when URL changes
|
||||||
if (loadedItems.length > 0 && !focusedItemId) {
|
useEffect(() => {
|
||||||
setFocusedItemId(loadedItems[0].id);
|
setFocusedCell(null);
|
||||||
}
|
setFocusedItemId(null);
|
||||||
}
|
}, [url]);
|
||||||
}, [itemsData, focusedItemId]);
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const saveItemMutation = useMutation({
|
const saveItemMutation = useMutation({
|
||||||
|
@ -568,31 +569,16 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Items List</h1>
|
<h1 className="text-2xl font-bold mb-4">Items List</h1>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="table-container">
|
||||||
<table className="min-w-full border-collapse border border-gray-300">
|
<table>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-300 p-2 bg-gray-100">Property</th>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<th key={item.id} className="border border-gray-300 p-2 bg-gray-100 relative">
|
|
||||||
{item.name || `Item ${index + 1}`}
|
|
||||||
<button
|
|
||||||
onClick={() => removeItem(index)}
|
|
||||||
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
|
|
||||||
title="Delete item"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* Name row */}
|
{/* Name row */}
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-300 p-2 font-medium">Name</td>
|
<td className="font-medium">Name</td>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<td key={`name-${item.id}`} className="border border-gray-300 p-2">
|
<td key={`name-${item.id}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
<TypeaheadInput
|
<TypeaheadInput
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onInput={(value) => updateItem(index, 'name', value)}
|
onInput={(value) => updateItem(index, 'name', value)}
|
||||||
|
@ -601,15 +587,24 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
onFocus={() => setFocusedItemId(item.id)}
|
onFocus={() => setFocusedItemId(item.id)}
|
||||||
placeholder="Enter item name"
|
placeholder="Enter item name"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="delete-button item-delete"
|
||||||
|
title="Delete item"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Description row */}
|
{/* Description row */}
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border border-gray-300 p-2 font-medium">Description</td>
|
<td className="font-medium">Description</td>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<td key={`description-${item.id}`} className="border border-gray-300 p-2">
|
<td key={`description-${item.id}`}>
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onInput={(value) => updateItem(index, 'description', value)}
|
onInput={(value) => updateItem(index, 'description', value)}
|
||||||
|
@ -627,18 +622,18 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
const propertyLabel = propertyLabels[property] || property;
|
const propertyLabel = propertyLabels[property] || property;
|
||||||
return (
|
return (
|
||||||
<tr key={property}>
|
<tr key={property}>
|
||||||
<td className="border border-gray-300 p-2 font-medium relative">
|
<td className="font-medium relative">
|
||||||
{propertyLabel}
|
{propertyLabel}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeProperty(property)}
|
onClick={() => removeProperty(property)}
|
||||||
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
|
className="delete-button property-delete"
|
||||||
title="Delete property"
|
title="Delete property"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<td key={`${property}-${item.id}`} className="border border-gray-300 p-2">
|
<td key={`${property}-${item.id}`}>
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={item.customProperties[property] || ''}
|
value={item.customProperties[property] || ''}
|
||||||
onInput={(value) => updateItem(index, property, value)}
|
onInput={(value) => updateItem(index, property, value)}
|
||||||
|
|
Loading…
Add table
Reference in a new issue