Initial commit

This commit is contained in:
michaeltieso
2025-12-01 14:49:17 +00:00
commit 3480888eaa
92 changed files with 16631 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
'use client';
import { useState } from 'react';
import ImageUpload from '@/components/image-upload';
import RichTextEditor from '@/components/RichTextEditor';
interface WishlistFormData {
name: string;
slug: string;
description: string;
preferences: string;
imageUrl: string;
isPublic: boolean;
}
interface CreateWishlistModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (data: WishlistFormData) => Promise<void>;
error?: string;
}
export default function CreateWishlistModal({
isOpen,
onClose,
onCreate,
error,
}: CreateWishlistModalProps) {
const [formData, setFormData] = useState<WishlistFormData>({
name: '',
slug: '',
description: '',
preferences: '',
imageUrl: '',
isPublic: true,
});
const [isCreating, setIsCreating] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const handleNameChange = (name: string) => {
setFormData((prev) => ({
...prev,
name,
slug: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsCreating(true);
try {
await onCreate(formData);
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
} finally {
setIsCreating(false);
}
};
const handleClose = () => {
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-80 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 shadow-2xl">
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
Create New Wishlist
</h2>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Name *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Slug (URL-friendly)
</label>
<input
type="text"
required
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.slug}
onChange={(e) =>
setFormData((prev) => ({ ...prev, slug: e.target.value }))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-1">
Description
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 dark:text-white dark:bg-gray-700"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value,
}))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-2">
General Interests & Preferences
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Share general things you like - hobbies, styles, colors, brands, etc. This appears before your wishlist items.
</p>
<RichTextEditor
value={formData.preferences}
onChange={(html) => setFormData((prev) => ({ ...prev, preferences: html }))}
placeholder="e.g., I love anything purple, enjoy sci-fi books, prefer sustainable brands..."
/>
</div>
<ImageUpload
currentImageUrl={formData.imageUrl}
onImageChange={(url) => setFormData((prev) => ({ ...prev, imageUrl: url }))}
onUploadStateChange={setIsImageUploading}
type="wishlist"
label="Wishlist Image"
/>
<div className="flex items-center">
<input
type="checkbox"
id="isPublic"
className="h-5 w-5 text-indigo-600 border-2 border-gray-300 rounded focus:ring-2 focus:ring-indigo-500"
checked={formData.isPublic}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isPublic: e.target.checked,
}))
}
/>
<label
htmlFor="isPublic"
className="ml-2 block text-base font-medium text-gray-900 dark:text-gray-200"
>
Make Public
</label>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-6 py-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg text-base font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating || isImageUploading}
className="px-6 py-3 border border-transparent rounded-lg text-base font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImageUploading ? 'Uploading...' : isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { type Item } from '@/lib/api';
interface ItemCardProps {
item: Item;
onEdit: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}
export default function ItemCard({
item,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: ItemCardProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="flex items-stretch">
{/* Arrow buttons on the left */}
<div className="flex flex-col w-12 border-r border-gray-200 dark:border-gray-700">
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
disabled={isFirst}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
disabled={isLast}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div className="flex-1 p-4 flex gap-3">
{/* Image on Left */}
{item.imageUrl && (
<img
src={item.imageUrl}
alt={item.name}
className="w-16 h-16 object-cover rounded border border-gray-200 dark:border-gray-600 flex-shrink-0"
/>
)}
<div className="flex-1">
<h5 className="text-base font-semibold text-gray-900 dark:text-white">
{item.name}
</h5>
{item.description && (
<p className="text-base text-gray-600 dark:text-gray-400 mt-1">
{item.description}
</p>
)}
{item.price && (
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
${item.price.toFixed(2)} {item.currency}
</p>
)}
</div>
</div>
<div className="flex flex-col w-16 border-l border-gray-200 dark:border-gray-700">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
title="Edit item"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={onDelete}
className="flex-1 flex items-center justify-center text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
title="Delete item"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import ImageUpload from '@/components/image-upload';
import PurchaseUrlFields from './PurchaseUrlFields';
import { type Item } from '@/lib/api';
interface ItemFormProps {
item?: Partial<Item>;
onSubmit: (item: Partial<Item>) => Promise<void>;
onCancel: () => void;
mode: 'create' | 'edit';
error?: string;
}
export default function ItemForm({ item, onSubmit, onCancel, mode, error }: ItemFormProps) {
const [formData, setFormData] = useState<Partial<Item>>(
item || {
name: '',
description: '',
price: null,
currency: 'USD',
quantity: 1,
imageUrl: '',
purchaseUrls: [],
}
);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
try {
await onSubmit(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="mb-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<h5 className="text-base font-medium text-gray-900 dark:text-white mb-3">
{mode === 'create' ? 'Add New Item' : 'Edit Item'}
</h5>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Name *
</label>
<input
type="text"
required
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
/>
</div>
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Price
</label>
<input
type="number"
step="0.01"
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.price || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price: e.target.value ? parseFloat(e.target.value) : null,
}))
}
/>
</div>
<div className="md:col-span-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
rows={2}
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={formData.description || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
/>
</div>
<div className="md:col-span-2">
<ImageUpload
currentImageUrl={formData.imageUrl || ''}
onImageChange={(url) =>
setFormData((prev) => ({ ...prev, imageUrl: url }))
}
onUploadStateChange={setIsImageUploading}
type="item"
label="Item Image"
/>
</div>
<div className="md:col-span-2">
<PurchaseUrlFields
purchaseUrls={formData.purchaseUrls || []}
onChange={(urls) =>
setFormData((prev) => ({ ...prev, purchaseUrls: urls }))
}
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isImageUploading || isSubmitting}
className="px-4 py-2 text-base bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImageUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : mode === 'create' ? 'Add Item' : 'Save'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,70 @@
interface PurchaseUrl {
label: string;
url: string;
}
interface PurchaseUrlFieldsProps {
purchaseUrls: PurchaseUrl[];
onChange: (urls: PurchaseUrl[]) => void;
}
export default function PurchaseUrlFields({ purchaseUrls, onChange }: PurchaseUrlFieldsProps) {
const handleAdd = () => {
onChange([...purchaseUrls, { label: '', url: '' }]);
};
const handleRemove = (index: number) => {
onChange(purchaseUrls.filter((_, i) => i !== index));
};
const handleUpdate = (index: number, field: 'label' | 'url', value: string) => {
const updated = [...purchaseUrls];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Purchase URLs
</label>
<div className="space-y-2">
{purchaseUrls.map((urlObj, index) => (
<div key={index} className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-start">
<input
type="text"
placeholder="Label (e.g., Amazon)"
value={urlObj.label}
onChange={(e) => handleUpdate(index, 'label', e.target.value)}
className="w-full sm:w-1/3 px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<input
type="url"
placeholder="https://example.com"
value={urlObj.url}
onChange={(e) => handleUpdate(index, 'url', e.target.value)}
className="flex-1 px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<button
type="button"
onClick={() => handleRemove(index)}
className="px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded cursor-pointer"
title="Remove URL"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
type="button"
onClick={handleAdd}
className="w-full px-3 py-2 text-base border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors cursor-pointer"
>
+ Add URL
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { type Settings } from '@/lib/api';
interface SettingsSectionProps {
settings: Settings;
onUpdate: (settings: Settings) => Promise<void>;
}
export default function SettingsSection({ settings, onUpdate }: SettingsSectionProps) {
const [editingSettings, setEditingSettings] = useState(false);
const [settingsForm, setSettingsForm] = useState<Settings>(settings);
const [settingsError, setSettingsError] = useState('');
const startEditingSettings = () => {
setEditingSettings(true);
setSettingsForm({ ...settings });
setSettingsError('');
};
const cancelEditingSettings = () => {
setEditingSettings(false);
setSettingsError('');
};
const handleUpdateSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSettingsError('');
try {
await onUpdate(settingsForm);
setEditingSettings(false);
} catch (error: any) {
setSettingsError(error.message || 'Failed to update settings');
}
};
return (
<div className="mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Settings
</h2>
{!editingSettings && (
<button
onClick={startEditingSettings}
className="px-4 py-2 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer"
>
Edit Settings
</button>
)}
</div>
</div>
<div className="p-5">
{settingsError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{settingsError}
</div>
)}
{editingSettings ? (
<form onSubmit={handleUpdateSettings} className="space-y-4">
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Title
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.siteTitle}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, siteTitle: e.target.value }))
}
placeholder="Wishlist"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
This is used for the page title and homepage header
</p>
</div>
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Homepage Subtext
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.homepageSubtext}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, homepageSubtext: e.target.value }))
}
placeholder="Browse and explore available wishlists"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
This appears below the title on the homepage
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center mb-3">
<input
type="checkbox"
id="passwordLockEnabled"
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
checked={settingsForm.passwordLockEnabled}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLockEnabled: e.target.checked }))
}
/>
<label htmlFor="passwordLockEnabled" className="ml-2 block text-base font-medium text-gray-700 dark:text-gray-300">
Enable Password Lock
</label>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
When enabled, visitors must enter a password to access the website
</p>
{settingsForm.passwordLockEnabled && (
<div>
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Site Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
value={settingsForm.passwordLock || ''}
onChange={(e) =>
setSettingsForm((prev) => ({ ...prev, passwordLock: e.target.value }))
}
placeholder="Enter password (leave blank to keep current)"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Leave blank to keep the current password unchanged
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={cancelEditingSettings}
className="px-4 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-base bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 cursor-pointer"
>
Save Settings
</button>
</div>
</form>
) : (
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Site Title</p>
<p className="text-base text-gray-900 dark:text-white">{settings.siteTitle}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Homepage Subtext</p>
<p className="text-base text-gray-900 dark:text-white">{settings.homepageSubtext}</p>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Password Lock</p>
<p className="text-base text-gray-900 dark:text-white">
{settings.passwordLockEnabled ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
Enabled
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">
Disabled
</span>
)}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
interface Stats {
totalWishlists: number;
publicWishlists: number;
totalItems: number;
}
interface StatsGridProps {
stats: Stats;
}
export default function StatsGrid({ stats }: StatsGridProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 mb-8">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">📋</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Total Wishlists
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalWishlists}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">🌐</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Public Wishlists
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.publicWishlists}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 text-3xl">🎁</div>
<div className="ml-4 w-0 flex-1">
<dl>
<dt className="text-base font-medium text-gray-500 dark:text-gray-400 truncate">
Total Items
</dt>
<dd className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalItems}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,482 @@
'use client';
import { useState } from 'react';
import { type Wishlist, type Item, itemsApi } from '@/lib/api';
import ImageUpload from '@/components/image-upload';
import RichTextEditor from '@/components/RichTextEditor';
import ItemCard from './ItemCard';
import ItemForm from './ItemForm';
interface WishlistCardProps {
wishlist: Wishlist;
itemCount: number;
onUpdate: (id: string, data: Partial<Wishlist>) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onMoveUp: (id: string) => Promise<void>;
onMoveDown: (id: string) => Promise<void>;
isFirst: boolean;
isLast: boolean;
onItemsChange: () => void;
}
export default function WishlistCard({
wishlist,
itemCount,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
onItemsChange,
}: WishlistCardProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: '',
slug: '',
description: '',
preferences: '',
imageUrl: '',
isPublic: true,
});
const [editError, setEditError] = useState('');
const [isWishlistImageUploading, setIsWishlistImageUploading] = useState(false);
const [expandedWishlistId, setExpandedWishlistId] = useState<string | null>(null);
const [wishlistItems, setWishlistItems] = useState<Item[]>([]);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [showAddItemForm, setShowAddItemForm] = useState(false);
const [newItemError, setNewItemError] = useState<string>('');
const startEditing = () => {
setEditingId(wishlist.id);
setEditForm({
name: wishlist.name,
slug: wishlist.slug,
description: wishlist.description || '',
preferences: wishlist.preferences || '',
imageUrl: wishlist.imageUrl || '',
isPublic: wishlist.isPublic,
});
setEditError('');
};
const cancelEditing = () => {
setEditingId(null);
setEditError('');
};
const handleEditNameChange = (name: string) => {
setEditForm((prev) => ({
...prev,
name,
slug: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}));
};
const handleUpdateWishlist = async (e: React.FormEvent) => {
e.preventDefault();
setEditError('');
try {
await onUpdate(wishlist.id, editForm);
setEditingId(null);
} catch (error: any) {
setEditError(error.message || 'Failed to update wishlist');
}
};
const toggleWishlistExpand = async () => {
if (expandedWishlistId === wishlist.id) {
setExpandedWishlistId(null);
} else {
setExpandedWishlistId(wishlist.id);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
}
};
const handleCreateItem = async (itemData: Partial<Item>) => {
setNewItemError('');
try {
await itemsApi.create(wishlist.id, itemData);
setShowAddItemForm(false);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error: any) {
setNewItemError(error.message || 'Failed to create item');
throw error;
}
};
const handleUpdateItem = async (itemData: Partial<Item>) => {
if (!editingItemId) return;
try {
await itemsApi.update(editingItemId, itemData);
setEditingItemId(null);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error: any) {
alert(error.message || 'Failed to update item');
throw error;
}
};
const handleDeleteItem = async (itemId: string) => {
if (!confirm('Are you sure you want to delete this item?')) return;
try {
await itemsApi.delete(itemId);
const items = await itemsApi.getAll(wishlist.id);
setWishlistItems(items);
onItemsChange();
} catch (error) {
alert('Failed to delete item');
}
};
const handleMoveItemUp = async (itemId: string) => {
const currentIndex = wishlistItems.findIndex((item) => item.id === itemId);
if (currentIndex <= 0) return;
try {
await itemsApi.reorder(itemId, currentIndex - 1);
const updatedItems = await itemsApi.getAll(wishlist.id);
setWishlistItems(updatedItems);
} catch (error: any) {
alert(error?.message || 'Failed to reorder item');
}
};
const handleMoveItemDown = async (itemId: string) => {
const currentIndex = wishlistItems.findIndex((item) => item.id === itemId);
if (currentIndex === -1 || currentIndex === wishlistItems.length - 1) return;
try {
await itemsApi.reorder(itemId, currentIndex + 1);
const updatedItems = await itemsApi.getAll(wishlist.id);
setWishlistItems(updatedItems);
} catch (error: any) {
alert(error?.message || 'Failed to reorder item');
}
};
const editingItem = wishlistItems.find((item) => item.id === editingItemId);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-5 border-b border-gray-200 dark:border-gray-700">
{editError && (
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded-lg text-base">
{editError}
</div>
)}
<div className="flex items-stretch -m-5">
{/* Arrow buttons on the left */}
<div className="flex flex-col w-12 border-r border-gray-200 dark:border-gray-700">
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp(wishlist.id);
}}
disabled={isFirst}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown(wishlist.id);
}}
disabled={isLast}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div className="flex-1 p-5">
{editingId === wishlist.id ? (
// Inline Edit Mode
<div className="space-y-3">
<ImageUpload
currentImageUrl={editForm.imageUrl}
onImageChange={(url) =>
setEditForm((prev) => ({ ...prev, imageUrl: url }))
}
onUploadStateChange={setIsWishlistImageUploading}
type="wishlist"
label="Wishlist Image"
/>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Wishlist Name *
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
className="text-lg font-bold px-2 py-1 border-2 border-indigo-500 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white flex-1"
placeholder="Wishlist name"
/>
<label className="flex items-center gap-2 text-base">
<input
type="checkbox"
checked={editForm.isPublic}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
isPublic: e.target.checked,
}))
}
className="h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
<span className="text-gray-700 dark:text-gray-300">Public</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
URL Slug *
</label>
<input
type="text"
value={editForm.slug}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, slug: e.target.value }))
}
className="text-base px-2 py-1 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white w-full"
placeholder="url-slug"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={editForm.description}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="text-base px-2 py-1 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white w-full"
placeholder="Description"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preferences
</label>
<RichTextEditor
value={editForm.preferences}
onChange={(html) => setEditForm((prev) => ({ ...prev, preferences: html }))}
placeholder="General interests and preferences..."
/>
</div>
<p className="text-base text-gray-500 dark:text-gray-500">
{itemCount} items
</p>
</div>
</div>
) : (
// Display Mode
<div className="flex items-start gap-3">
{wishlist.imageUrl && (
<img
src={wishlist.imageUrl}
alt={wishlist.name}
className="w-20 h-20 object-cover rounded border border-gray-200 dark:border-gray-600"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{wishlist.name}
</h3>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-base font-medium ${
wishlist.isPublic
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
}`}
>
{wishlist.isPublic ? 'Public' : 'Private'}
</span>
</div>
<p className="text-base text-gray-600 dark:text-gray-400 mb-1">
/{wishlist.slug}
</p>
{wishlist.description && (
<p className="text-base text-gray-600 dark:text-gray-400 mb-1">
{wishlist.description}
</p>
)}
<p className="text-base text-gray-500 dark:text-gray-500">
{itemCount} items
</p>
</div>
</div>
)}
</div>
<div className="flex flex-col w-24 border-l border-gray-200 dark:border-gray-700">
{editingId === wishlist.id ? (
<>
<button
onClick={cancelEditing}
className="flex-1 flex items-center justify-center text-base font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700"
title="Cancel"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
handleUpdateWishlist(e as any);
}}
disabled={isWishlistImageUploading}
className="flex-1 flex items-center justify-center text-base font-semibold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={isWishlistImageUploading ? "Uploading..." : "Save"}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</>
) : (
<>
<button
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="flex-1 flex items-center justify-center text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
title="Edit wishlist"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(wishlist.id);
}}
className="flex-1 flex items-center justify-center text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
title="Delete wishlist"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
{/* Expand/Collapse Row */}
<button
onClick={toggleWishlistExpand}
className="w-full px-5 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-center gap-2 text-base font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
{expandedWishlistId === wishlist.id ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span>Hide Items</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span>Show Items ({itemCount})</span>
</>
)}
</button>
{/* Items Section */}
{expandedWishlistId === wishlist.id && (
<div className="p-5 bg-gray-50 dark:bg-gray-900/50">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
Items ({wishlistItems.length})
</h4>
<button
onClick={() => {
setShowAddItemForm(true);
setNewItemError('');
}}
className="px-4 py-2 text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors cursor-pointer"
>
+ Add Item
</button>
</div>
{/* Add Item Form */}
{showAddItemForm && (
<ItemForm
mode="create"
onSubmit={handleCreateItem}
onCancel={() => {
setShowAddItemForm(false);
setNewItemError('');
}}
error={newItemError}
/>
)}
{/* Items List */}
{wishlistItems.length === 0 ? (
<p className="text-base text-gray-500 dark:text-gray-400 text-center py-4">
No items yet
</p>
) : (
<div className="space-y-3">
{wishlistItems.map((item, index) => (
<div key={item.id}>
{editingItemId === item.id ? (
<ItemForm
mode="edit"
item={editingItem}
onSubmit={handleUpdateItem}
onCancel={() => setEditingItemId(null)}
/>
) : (
<ItemCard
item={item}
onEdit={() => setEditingItemId(item.id)}
onDelete={() => handleDeleteItem(item.id)}
onMoveUp={() => handleMoveItemUp(item.id)}
onMoveDown={() => handleMoveItemDown(item.id)}
isFirst={index === 0}
isLast={index === wishlistItems.length - 1}
/>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}