Skip to content

Commit f971427

Browse files
committed
Add rich text preferences field for general interests to wishlists
1 parent 868ca48 commit f971427

9 files changed

Lines changed: 848 additions & 72 deletions

File tree

app/[slug]/page.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,28 @@ export default function PublicWishlistPage() {
156156
</svg>
157157
Back to Home
158158
</a>
159+
160+
{/* Preferences Section */}
161+
{wishlist.preferences && (
162+
<div className="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
163+
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
164+
General Interests & Preferences
165+
</h2>
166+
<div
167+
className="prose prose-indigo dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 [&_a]:text-indigo-600 [&_a]:dark:text-indigo-400 [&_a]:hover:underline"
168+
dangerouslySetInnerHTML={{ __html: wishlist.preferences }}
169+
onClick={(e) => {
170+
// Make all links open in new tab
171+
const target = e.target as HTMLElement;
172+
if (target.tagName === 'A') {
173+
e.preventDefault();
174+
window.open((target as HTMLAnchorElement).href, '_blank', 'noopener,noreferrer');
175+
}
176+
}}
177+
/>
178+
</div>
179+
)}
180+
159181
{/* Controls */}
160182
<div className="mb-6 flex items-center justify-between">
161183
<div className="flex items-center space-x-4">

components/RichTextEditor.tsx

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
'use client';
2+
3+
import { LexicalComposer } from '@lexical/react/LexicalComposer';
4+
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
5+
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
6+
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
7+
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
8+
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
9+
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
10+
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
11+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
12+
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
13+
import { $getRoot, $insertNodes, $getSelection, $isRangeSelection } from 'lexical';
14+
import { ListNode, ListItemNode } from '@lexical/list';
15+
import { LinkNode, AutoLinkNode, $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
16+
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
17+
import { useEffect, useState } from 'react';
18+
19+
// Toolbar Component
20+
function ToolbarPlugin() {
21+
const [editor] = useLexicalComposerContext();
22+
const [showLinkModal, setShowLinkModal] = useState(false);
23+
const [linkUrl, setLinkUrl] = useState('');
24+
const [linkText, setLinkText] = useState('');
25+
26+
const formatBold = () => {
27+
editor.dispatchCommand({ type: 'FORMAT_TEXT_COMMAND', payload: 'bold' } as any, undefined);
28+
};
29+
30+
const formatItalic = () => {
31+
editor.dispatchCommand({ type: 'FORMAT_TEXT_COMMAND', payload: 'italic' } as any, undefined);
32+
};
33+
34+
const formatBulletList = () => {
35+
editor.dispatchCommand({ type: 'INSERT_UNORDERED_LIST_COMMAND' } as any, undefined);
36+
};
37+
38+
const formatNumberedList = () => {
39+
editor.dispatchCommand({ type: 'INSERT_ORDERED_LIST_COMMAND' } as any, undefined);
40+
};
41+
42+
const openLinkModal = () => {
43+
// Get selected text if any
44+
editor.getEditorState().read(() => {
45+
const selection = $getSelection();
46+
if ($isRangeSelection(selection)) {
47+
const text = selection.getTextContent();
48+
setLinkText(text);
49+
}
50+
});
51+
setLinkUrl('');
52+
setShowLinkModal(true);
53+
};
54+
55+
const insertLink = () => {
56+
if (!linkUrl) return;
57+
58+
editor.update(() => {
59+
const selection = $getSelection();
60+
if ($isRangeSelection(selection)) {
61+
// If there's selected text, convert it to a link
62+
if (selection.getTextContent()) {
63+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
64+
} else if (linkText) {
65+
// If no selection but we have link text, insert new link
66+
const linkNode = $createLinkNode(linkUrl);
67+
linkNode.append($getRoot().getFirstChild() as any);
68+
selection.insertNodes([linkNode]);
69+
}
70+
}
71+
});
72+
73+
setShowLinkModal(false);
74+
setLinkUrl('');
75+
setLinkText('');
76+
};
77+
78+
return (
79+
<>
80+
<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">
81+
<button
82+
type="button"
83+
onClick={formatBold}
84+
className="px-3 py-1.5 text-sm font-bold hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
85+
title="Bold"
86+
>
87+
B
88+
</button>
89+
<button
90+
type="button"
91+
onClick={formatItalic}
92+
className="px-3 py-1.5 text-sm italic hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
93+
title="Italic"
94+
>
95+
I
96+
</button>
97+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
98+
<button
99+
type="button"
100+
onClick={formatBulletList}
101+
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
102+
title="Bullet List"
103+
>
104+
• List
105+
</button>
106+
<button
107+
type="button"
108+
onClick={formatNumberedList}
109+
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
110+
title="Numbered List"
111+
>
112+
1. List
113+
</button>
114+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
115+
<button
116+
type="button"
117+
onClick={openLinkModal}
118+
className="px-3 py-1.5 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
119+
title="Insert Link"
120+
>
121+
🔗 Link
122+
</button>
123+
</div>
124+
125+
{/* Link Modal */}
126+
{showLinkModal && (
127+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => setShowLinkModal(false)}>
128+
<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()}>
129+
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Insert Link</h3>
130+
<div className="space-y-4">
131+
<div>
132+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
133+
Link Text {!linkText && '(optional)'}
134+
</label>
135+
<input
136+
type="text"
137+
value={linkText}
138+
onChange={(e) => setLinkText(e.target.value)}
139+
placeholder="Click here"
140+
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"
141+
/>
142+
</div>
143+
<div>
144+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
145+
URL *
146+
</label>
147+
<input
148+
type="url"
149+
value={linkUrl}
150+
onChange={(e) => setLinkUrl(e.target.value)}
151+
placeholder="https://example.com"
152+
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"
153+
autoFocus
154+
/>
155+
</div>
156+
</div>
157+
<div className="mt-6 flex justify-end gap-3">
158+
<button
159+
type="button"
160+
onClick={() => setShowLinkModal(false)}
161+
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"
162+
>
163+
Cancel
164+
</button>
165+
<button
166+
type="button"
167+
onClick={insertLink}
168+
disabled={!linkUrl}
169+
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
170+
>
171+
Insert Link
172+
</button>
173+
</div>
174+
</div>
175+
</div>
176+
)}
177+
</>
178+
);
179+
}
180+
181+
// Plugin to load initial HTML content
182+
function InitialContentPlugin({ html }: { html?: string }) {
183+
const [editor] = useLexicalComposerContext();
184+
185+
useEffect(() => {
186+
if (html) {
187+
editor.update(() => {
188+
const parser = new DOMParser();
189+
const dom = parser.parseFromString(html, 'text/html');
190+
const nodes = $generateNodesFromDOM(editor, dom);
191+
$getRoot().clear();
192+
$getRoot().select();
193+
$insertNodes(nodes);
194+
});
195+
}
196+
}, []); // Only run once on mount
197+
198+
return null;
199+
}
200+
201+
interface RichTextEditorProps {
202+
value?: string;
203+
onChange: (html: string) => void;
204+
placeholder?: string;
205+
}
206+
207+
export default function RichTextEditor({ value, onChange, placeholder = 'Enter text...' }: RichTextEditorProps) {
208+
const initialConfig = {
209+
namespace: 'WishlistPreferences',
210+
theme: {
211+
paragraph: 'mb-2',
212+
list: {
213+
ul: 'list-disc list-inside mb-2',
214+
ol: 'list-decimal list-inside mb-2',
215+
},
216+
link: 'text-indigo-600 dark:text-indigo-400 hover:underline',
217+
},
218+
onError: (error: Error) => {
219+
console.error(error);
220+
},
221+
nodes: [
222+
HeadingNode,
223+
ListNode,
224+
ListItemNode,
225+
QuoteNode,
226+
LinkNode,
227+
AutoLinkNode,
228+
],
229+
};
230+
231+
const handleChange = (editorState: any, editor: any) => {
232+
editor.read(() => {
233+
const html = $generateHtmlFromNodes(editor);
234+
onChange(html);
235+
});
236+
};
237+
238+
return (
239+
<LexicalComposer initialConfig={initialConfig}>
240+
<div className="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
241+
<ToolbarPlugin />
242+
<RichTextPlugin
243+
contentEditable={
244+
<ContentEditable className="min-h-[150px] p-4 outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
245+
}
246+
placeholder={
247+
<div className="absolute top-14 left-4 text-gray-400 pointer-events-none">
248+
{placeholder}
249+
</div>
250+
}
251+
ErrorBoundary={LexicalErrorBoundary}
252+
/>
253+
<HistoryPlugin />
254+
<ListPlugin />
255+
<LinkPlugin />
256+
<OnChangePlugin onChange={handleChange} />
257+
{value && <InitialContentPlugin html={value} />}
258+
</div>
259+
</LexicalComposer>
260+
);
261+
}

components/admin/CreateWishlistModal.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import { useState } from 'react';
44
import ImageUpload from '@/components/image-upload';
5+
import RichTextEditor from '@/components/RichTextEditor';
56

67
interface WishlistFormData {
78
name: string;
89
slug: string;
910
description: string;
11+
preferences: string;
1012
imageUrl: string;
1113
isPublic: boolean;
1214
}
@@ -28,6 +30,7 @@ export default function CreateWishlistModal({
2830
name: '',
2931
slug: '',
3032
description: '',
33+
preferences: '',
3134
imageUrl: '',
3235
isPublic: true,
3336
});
@@ -51,14 +54,14 @@ export default function CreateWishlistModal({
5154

5255
try {
5356
await onCreate(formData);
54-
setFormData({ name: '', slug: '', description: '', imageUrl: '', isPublic: true });
57+
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
5558
} finally {
5659
setIsCreating(false);
5760
}
5861
};
5962

6063
const handleClose = () => {
61-
setFormData({ name: '', slug: '', description: '', imageUrl: '', isPublic: true });
64+
setFormData({ name: '', slug: '', description: '', preferences: '', imageUrl: '', isPublic: true });
6265
onClose();
6366
};
6467

@@ -119,6 +122,19 @@ export default function CreateWishlistModal({
119122
}
120123
/>
121124
</div>
125+
<div>
126+
<label className="block text-base font-medium text-gray-900 dark:text-gray-200 mb-2">
127+
General Interests & Preferences
128+
</label>
129+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
130+
Share general things you like - hobbies, styles, colors, brands, etc. This appears before your wishlist items.
131+
</p>
132+
<RichTextEditor
133+
value={formData.preferences}
134+
onChange={(html) => setFormData((prev) => ({ ...prev, preferences: html }))}
135+
placeholder="e.g., I love anything purple, enjoy sci-fi books, prefer sustainable brands..."
136+
/>
137+
</div>
122138
<ImageUpload
123139
currentImageUrl={formData.imageUrl}
124140
onImageChange={(url) => setFormData((prev) => ({ ...prev, imageUrl: url }))}

components/admin/WishlistCard.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react';
44
import { type Wishlist, type Item, itemsApi } from '@/lib/api';
55
import ImageUpload from '@/components/image-upload';
6+
import RichTextEditor from '@/components/RichTextEditor';
67
import ItemCard from './ItemCard';
78
import ItemForm from './ItemForm';
89

@@ -34,6 +35,7 @@ export default function WishlistCard({
3435
name: '',
3536
slug: '',
3637
description: '',
38+
preferences: '',
3739
imageUrl: '',
3840
isPublic: true,
3941
});
@@ -51,6 +53,7 @@ export default function WishlistCard({
5153
name: wishlist.name,
5254
slug: wishlist.slug,
5355
description: wishlist.description || '',
56+
preferences: wishlist.preferences || '',
5457
imageUrl: wishlist.imageUrl || '',
5558
isPublic: wishlist.isPublic,
5659
});
@@ -260,6 +263,16 @@ export default function WishlistCard({
260263
placeholder="Description"
261264
rows={2}
262265
/>
266+
<div>
267+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
268+
Preferences
269+
</label>
270+
<RichTextEditor
271+
value={editForm.preferences}
272+
onChange={(html) => setEditForm((prev) => ({ ...prev, preferences: html }))}
273+
placeholder="General interests and preferences..."
274+
/>
275+
</div>
263276
<p className="text-base text-gray-500 dark:text-gray-500">
264277
{itemCount} items
265278
</p>

lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface Wishlist {
6363
name: string;
6464
slug: string;
6565
description: string | null;
66+
preferences: string | null;
6667
imageUrl: string | null;
6768
isPublic: boolean;
6869
sortOrder: number;

0 commit comments

Comments
 (0)