Initial commit
This commit is contained in:
309
components/RichTextEditor.tsx
Normal file
309
components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
|
||||
import { $getRoot, $insertNodes, $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical';
|
||||
import { ListNode, ListItemNode } from '@lexical/list';
|
||||
import { LinkNode, AutoLinkNode, $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { HeadingNode, QuoteNode, $createHeadingNode } from '@lexical/rich-text';
|
||||
import { $setBlocksType } from '@lexical/selection';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Toolbar Component
|
||||
function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [linkText, setLinkText] = useState('');
|
||||
|
||||
const formatBold = () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
};
|
||||
|
||||
const formatItalic = () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
};
|
||||
|
||||
const formatUnderline = () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
};
|
||||
|
||||
const formatHeading = (level: 'h2' | 'h3') => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode(level));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatBulletList = () => {
|
||||
editor.dispatchCommand({ type: 'INSERT_UNORDERED_LIST_COMMAND' } as any, undefined);
|
||||
};
|
||||
|
||||
const formatNumberedList = () => {
|
||||
editor.dispatchCommand({ type: 'INSERT_ORDERED_LIST_COMMAND' } as any, undefined);
|
||||
};
|
||||
|
||||
const openLinkModal = () => {
|
||||
// Get selected text if any
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const text = selection.getTextContent();
|
||||
setLinkText(text);
|
||||
}
|
||||
});
|
||||
setLinkUrl('');
|
||||
setShowLinkModal(true);
|
||||
};
|
||||
|
||||
const insertLink = () => {
|
||||
if (!linkUrl) return;
|
||||
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
// If there's selected text, convert it to a link
|
||||
if (selection.getTextContent()) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
|
||||
} else if (linkText) {
|
||||
// If no selection but we have link text, insert new link
|
||||
const linkNode = $createLinkNode(linkUrl);
|
||||
linkNode.append($getRoot().getFirstChild() as any);
|
||||
selection.insertNodes([linkNode]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setShowLinkModal(false);
|
||||
setLinkUrl('');
|
||||
setLinkText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-t-lg flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatBold}
|
||||
className="px-3 py-1.5 text-sm font-bold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Bold"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatItalic}
|
||||
className="px-3 py-1.5 text-sm italic hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Italic"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatUnderline}
|
||||
className="px-3 py-1.5 text-sm underline hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Underline"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formatHeading('h2')}
|
||||
className="px-3 py-1.5 text-sm font-bold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formatHeading('h3')}
|
||||
className="px-3 py-1.5 text-sm font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatBulletList}
|
||||
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Bullet List"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatNumberedList}
|
||||
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Numbered List"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLinkModal}
|
||||
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Insert Link"
|
||||
>
|
||||
🔗 Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link Modal */}
|
||||
{showLinkModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => setShowLinkModal(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Insert Link</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Link Text {!linkText && '(optional)'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={linkText}
|
||||
onChange={(e) => setLinkText(e.target.value)}
|
||||
placeholder="Click here"
|
||||
className="w-full px-3 py-2 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-3 py-2 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"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLinkModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertLink}
|
||||
disabled={!linkUrl}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Insert Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin to load initial HTML content
|
||||
function InitialContentPlugin({ html }: { html?: string }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (html) {
|
||||
editor.update(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(html, 'text/html');
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
$getRoot().clear();
|
||||
$getRoot().select();
|
||||
$insertNodes(nodes);
|
||||
});
|
||||
}
|
||||
}, []); // Only run once on mount
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value?: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({ value, onChange, placeholder = 'Enter text...' }: RichTextEditorProps) {
|
||||
const initialConfig = {
|
||||
namespace: 'WishlistPreferences',
|
||||
theme: {
|
||||
paragraph: 'mb-2',
|
||||
heading: {
|
||||
h2: 'text-2xl font-bold mb-3 mt-4 text-gray-900 dark:text-white',
|
||||
h3: 'text-xl font-semibold mb-2 mt-3 text-gray-900 dark:text-white',
|
||||
},
|
||||
list: {
|
||||
ul: 'list-disc list-inside mb-2',
|
||||
ol: 'list-decimal list-inside mb-2',
|
||||
},
|
||||
link: 'text-indigo-600 dark:text-indigo-400 hover:underline',
|
||||
text: {
|
||||
bold: 'font-bold',
|
||||
italic: 'italic',
|
||||
underline: 'underline',
|
||||
},
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error(error);
|
||||
},
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
LinkNode,
|
||||
AutoLinkNode,
|
||||
],
|
||||
};
|
||||
|
||||
const handleChange = (editorState: any, editor: any) => {
|
||||
editor.read(() => {
|
||||
const html = $generateHtmlFromNodes(editor);
|
||||
onChange(html);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div className="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<ToolbarPlugin />
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="min-h-[150px] p-4 outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
}
|
||||
placeholder={
|
||||
<div className="absolute top-14 left-4 text-gray-400 pointer-events-none">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
{value && <InitialContentPlugin html={value} />}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
186
components/admin/CreateWishlistModal.tsx
Normal file
186
components/admin/CreateWishlistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
components/admin/ItemCard.tsx
Normal file
102
components/admin/ItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
components/admin/ItemForm.tsx
Normal file
138
components/admin/ItemForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/admin/PurchaseUrlFields.tsx
Normal file
70
components/admin/PurchaseUrlFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
components/admin/SettingsSection.tsx
Normal file
184
components/admin/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
components/admin/StatsGrid.tsx
Normal file
69
components/admin/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
482
components/admin/WishlistCard.tsx
Normal file
482
components/admin/WishlistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
components/footer.tsx
Normal file
94
components/footer.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function Footer() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<p>Built for families</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin/login"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<a
|
||||
href="https://github.com/Reggio-Digital/wishlist"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="GitHub Repository"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-400">•</span>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300 font-medium"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Dark Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Light Mode</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
components/header.tsx
Normal file
62
components/header.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
actions?: React.ReactNode;
|
||||
maxWidth?: 'max-w-5xl' | 'max-w-7xl';
|
||||
}
|
||||
|
||||
export default function Header({ title, subtitle, imageUrl, actions, maxWidth = 'max-w-7xl' }: HeaderProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Admin Warning */}
|
||||
{isAuthenticated && (
|
||||
<div className="sticky top-0 z-50 bg-yellow-50 dark:bg-yellow-900 border-b border-yellow-200 dark:border-yellow-800">
|
||||
<div className={`${maxWidth} mx-auto py-3 px-4 sm:px-6 lg:px-8`}>
|
||||
<p className="text-center text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Warning: Viewing public wishlists may spoil surprises
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className={`${maxWidth} mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8`}>
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-8">
|
||||
{imageUrl && (
|
||||
<div className="md:w-64 flex-shrink-0">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
className="w-64 h-64 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={imageUrl ? 'flex-1 text-left' : 'flex-1 text-center'}>
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className={`text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-6 ${imageUrl ? '' : 'max-w-3xl mx-auto'}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{actions && (
|
||||
<div className={`flex flex-col sm:flex-row items-center gap-3 ${imageUrl ? 'justify-start' : 'justify-center'}`}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
components/image-upload.tsx
Normal file
302
components/image-upload.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface ImageUploadProps {
|
||||
currentImageUrl?: string;
|
||||
onImageChange: (url: string) => void;
|
||||
type: 'wishlist' | 'item';
|
||||
label?: string;
|
||||
onUploadStateChange?: (isUploading: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
currentImageUrl,
|
||||
onImageChange,
|
||||
type,
|
||||
label = 'Image',
|
||||
onUploadStateChange,
|
||||
}: ImageUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState(currentImageUrl || '');
|
||||
const [useUrl, setUseUrl] = useState(!!currentImageUrl);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pasteAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const uploadFile = (file: File) => {
|
||||
// Client-side validation
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setUploadError('Invalid file type. Please upload a JPEG, PNG, WebP, or GIF image.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
setUploadError('File is too large. Maximum size is 5MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setUploadError('');
|
||||
onUploadStateChange?.(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// Track upload progress
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
setUploadProgress(percentComplete);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
setImageUrl(data.url);
|
||||
onImageChange(data.url);
|
||||
setUploadProgress(100);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse response:', error);
|
||||
setUploadError('Failed to parse server response');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
setUploadError(data.error || 'Upload failed');
|
||||
} catch {
|
||||
setUploadError(`Upload failed with status ${xhr.status}`);
|
||||
}
|
||||
}
|
||||
setIsUploading(false);
|
||||
onUploadStateChange?.(false);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
xhr.addEventListener('error', () => {
|
||||
console.error('Upload error');
|
||||
setUploadError('Network error occurred during upload');
|
||||
setIsUploading(false);
|
||||
onUploadStateChange?.(false);
|
||||
});
|
||||
|
||||
// Handle abort
|
||||
xhr.addEventListener('abort', () => {
|
||||
setUploadError('Upload was cancelled');
|
||||
setIsUploading(false);
|
||||
onUploadStateChange?.(false);
|
||||
});
|
||||
|
||||
xhr.open('POST', '/uploads');
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadFile(file);
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
// Only handle paste if we're in upload mode (not URL mode)
|
||||
if (useUrl || imageUrl) return;
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
// Check if the item is an image
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add paste event listener
|
||||
useEffect(() => {
|
||||
const handlePasteEvent = (e: Event) => handlePaste(e as ClipboardEvent);
|
||||
|
||||
// Listen for paste events on the component's container
|
||||
const pasteArea = pasteAreaRef.current;
|
||||
if (pasteArea) {
|
||||
pasteArea.addEventListener('paste', handlePasteEvent);
|
||||
}
|
||||
|
||||
// Also listen globally when in upload mode and no image is set
|
||||
if (!useUrl && !imageUrl) {
|
||||
document.addEventListener('paste', handlePasteEvent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pasteArea) {
|
||||
pasteArea.removeEventListener('paste', handlePasteEvent);
|
||||
}
|
||||
document.removeEventListener('paste', handlePasteEvent);
|
||||
};
|
||||
}, [useUrl, imageUrl]);
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setImageUrl(url);
|
||||
onImageChange(url);
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageUrl('');
|
||||
onImageChange('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={pasteAreaRef} className="space-y-3" tabIndex={-1}>
|
||||
{label && (
|
||||
<label className="flex items-center gap-2 text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
<div className="group relative inline-block">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-help transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="Upload information"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-opacity absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg whitespace-nowrap z-10 pointer-events-none">
|
||||
Max 5MB. Allowed: JPEG, PNG, WebP, GIF. Images will be resized to max 800x800px and optimized.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{imageUrl ? (
|
||||
/* Image Preview with Remove Button */
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg border-2 border-gray-300 dark:border-gray-600"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg transition-colors"
|
||||
title="Remove image"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
) : (
|
||||
<>
|
||||
{/* Toggle between URL and File Upload */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseUrl(true)}
|
||||
className={`px-3 py-2 text-base rounded cursor-pointer transition-colors ${
|
||||
useUrl
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Use URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseUrl(false)}
|
||||
className={`px-3 py-2 text-base rounded cursor-pointer transition-colors ${
|
||||
!useUrl
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useUrl ? (
|
||||
/* URL Input */
|
||||
<div>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full px-3 py-2 text-base border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
value={imageUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* File Upload */
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent dark:bg-gray-700 dark:text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-indigo-600 dark:text-indigo-400 font-medium">
|
||||
💡 Tip: You can also paste an image directly (Ctrl+V / Cmd+V)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{uploadError && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-base">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress Bar */}
|
||||
{isUploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Uploading...</span>
|
||||
<span className="font-medium">{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-indigo-600 h-2.5 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
components/item-form.tsx
Normal file
265
components/item-form.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { scrapingApi, type Item } from '@/lib/api';
|
||||
import ImageUpload from './image-upload';
|
||||
|
||||
interface ItemFormProps {
|
||||
initialData?: Partial<Item>;
|
||||
onSubmit: (data: Partial<Item>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export default function ItemForm({ initialData, onSubmit, onCancel, isEditing = false }: ItemFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
price: initialData?.price?.toString() || '',
|
||||
currency: initialData?.currency || 'USD',
|
||||
quantity: initialData?.quantity?.toString() || '1',
|
||||
imageUrl: initialData?.imageUrl || '',
|
||||
purchaseUrl: initialData?.purchaseUrls?.[0]?.url || '',
|
||||
purchaseLabel: initialData?.purchaseUrls?.[0]?.label || '',
|
||||
});
|
||||
|
||||
const [scrapeUrl, setScrapeUrl] = useState('');
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [scrapeError, setScrapeError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const handleScrape = async () => {
|
||||
if (!scrapeUrl) return;
|
||||
|
||||
setIsScraping(true);
|
||||
setScrapeError('');
|
||||
|
||||
try {
|
||||
const data = await scrapingApi.scrapeUrl(scrapeUrl);
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: data.title || prev.name,
|
||||
description: data.description || prev.description,
|
||||
price: data.price?.toString() || prev.price,
|
||||
currency: data.currency || prev.currency,
|
||||
imageUrl: data.imageUrl || prev.imageUrl,
|
||||
purchaseUrl: scrapeUrl,
|
||||
purchaseLabel: new URL(scrapeUrl).hostname.replace('www.', ''),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setScrapeError(error.message || 'Failed to scrape URL');
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setSubmitError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const purchaseUrls = formData.purchaseUrl
|
||||
? [
|
||||
{
|
||||
url: formData.purchaseUrl,
|
||||
label: formData.purchaseLabel || 'Link',
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
await onSubmit({
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
price: formData.price ? parseFloat(formData.price) : null,
|
||||
currency: formData.currency,
|
||||
quantity: parseInt(formData.quantity) || 1,
|
||||
imageUrl: formData.imageUrl || null,
|
||||
purchaseUrls,
|
||||
});
|
||||
} catch (error: any) {
|
||||
setSubmitError(error.message || 'Failed to save item');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{submitError && (
|
||||
<div className="p-4 bg-red-50 text-red-800 rounded-md text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL Scraper */}
|
||||
{!isEditing && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Auto-fill from URL (optional)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={scrapeUrl}
|
||||
onChange={(e) => setScrapeUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrape}
|
||||
disabled={isScraping || !scrapeUrl}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 font-semibold transition-colors"
|
||||
>
|
||||
{isScraping ? 'Scraping...' : 'Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
{scrapeError && (
|
||||
<p className="mt-2 text-sm text-red-600">{scrapeError}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-600">
|
||||
Supports common retailers like Amazon, eBay, etc.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Item Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price & Quantity */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.price}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Only allow empty string or valid numbers (including decimals)
|
||||
if (value === '' || /^\d*\.?\d*$/.test(value)) {
|
||||
setFormData((prev) => ({ ...prev, price: value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.currency}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, currency: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.quantity}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, quantity: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Upload/URL */}
|
||||
<ImageUpload
|
||||
currentImageUrl={formData.imageUrl}
|
||||
onImageChange={(url) => setFormData((prev) => ({ ...prev, imageUrl: url }))}
|
||||
type="item"
|
||||
label="Product Image"
|
||||
/>
|
||||
|
||||
{/* Purchase Link */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Purchase Link
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/product"
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 mb-2"
|
||||
value={formData.purchaseUrl}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, purchaseUrl: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Link Label"
|
||||
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
|
||||
value={formData.purchaseLabel}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, purchaseLabel: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-3 border-2 border-gray-300 rounded-lg text-base font-semibold text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : isEditing ? 'Update Item' : 'Create Item'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
56
components/password-lock-guard.tsx
Normal file
56
components/password-lock-guard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
export default function PasswordLockGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip check for admin and lock pages
|
||||
if (pathname.startsWith('/admin') || pathname === '/lock') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkPasswordLock = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.settings.passwordLockEnabled) {
|
||||
setIsLocked(true);
|
||||
// Redirect to lock page
|
||||
router.push('/lock');
|
||||
} else {
|
||||
setIsChecking(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking password lock:', error);
|
||||
// On error, allow access to prevent site lockout
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPasswordLock();
|
||||
}, [pathname, router]);
|
||||
|
||||
// Show loading or nothing while checking
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If locked, don't render children (redirect is happening)
|
||||
if (isLocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
30
components/protected-route.tsx
Normal file
30
components/protected-route.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/admin/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
79
components/share-button.tsx
Normal file
79
components/share-button.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ShareButtonProps {
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ShareButton({
|
||||
title = 'Check out this wishlist!',
|
||||
text = 'I thought you might be interested in this wishlist.',
|
||||
url,
|
||||
className = ''
|
||||
}: ShareButtonProps) {
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
|
||||
const handleShare = async () => {
|
||||
// Use current URL if not provided
|
||||
const shareUrl = url || window.location.href;
|
||||
|
||||
// Check if Web Share API is supported
|
||||
if (!navigator.share) {
|
||||
setIsSupported(false);
|
||||
// Fallback: copy to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
alert('Link copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Unable to share or copy link');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share({
|
||||
title,
|
||||
text,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// User cancelled or share failed
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error sharing:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSupported && typeof window !== 'undefined' && !navigator.clipboard) {
|
||||
// Don't show button if neither share nor clipboard is supported
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`inline-flex items-center px-6 py-3 border-2 border-indigo-600 dark:border-indigo-500 text-base font-semibold rounded-lg text-indigo-600 dark:text-indigo-400 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-gray-700 transition-all cursor-pointer ${className}`}
|
||||
title="Share this page"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
);
|
||||
}
|
||||
59
components/theme-provider.tsx
Normal file
59
components/theme-provider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Load theme from localStorage
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
const initialTheme = savedTheme || 'light';
|
||||
|
||||
setTheme(initialTheme);
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user