added markdown notes for events
This commit is contained in:
@@ -18,6 +18,7 @@ My goal for this project is to be an all-in-one self hosted event planner for ma
|
|||||||
- Event type
|
- Event type
|
||||||
- Details
|
- Details
|
||||||
- Location
|
- Location
|
||||||
|
- [x] Markdown supported notes
|
||||||
- [x] Guest book (contact information)
|
- [x] Guest book (contact information)
|
||||||
- [x] Ability to switch between table or card view
|
- [x] Ability to switch between table or card view
|
||||||
- [x] Add Guests to events
|
- [x] Add Guests to events
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
import EventInfoDisplay from '@/components/EventInfoDisplay'
|
import EventInfoDisplay from '@/components/EventInfoDisplay'
|
||||||
import HeadingWithEdit from '@/components/HeadingWithEdit'
|
|
||||||
import { queries } from '@/lib/queries'
|
import { queries } from '@/lib/queries'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export async function PATCH(req: NextRequest, { params }: { params: { eventId: s
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await mutations.updateEvent(eventId, body);
|
const updated = await mutations.updateEvent(eventId, {
|
||||||
|
name: body.name,
|
||||||
|
date: body.date,
|
||||||
|
location: body.location,
|
||||||
|
notes: body.notes,
|
||||||
|
});
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PATCH EVENT]', error);
|
console.error('[PATCH EVENT]', error);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #fff5eb;
|
--color-background: #fff5eb;
|
||||||
@@ -76,3 +77,35 @@
|
|||||||
.btn-primary{
|
.btn-primary{
|
||||||
@apply bg-brand-primary text-brand-background border-0 hover:bg-brand-primary-500 transition-colors duration-300
|
@apply bg-brand-primary text-brand-background border-0 hover:bg-brand-primary-500 transition-colors duration-300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
@apply text-2xl font-bold m-0 p-0
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
@apply text-xl font-bold m-0 p-0
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
@apply p-0 m-0
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
@apply text-brand-primary-500
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply block w-full px-4 py-2 rounded-md text-sm text-gray-900 bg-white border border-gray-300 shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-primary-500 focus:border-brand-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bordered {
|
||||||
|
@apply border border-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
@apply block w-full px-4 py-2 rounded-md text-sm text-gray-900 bg-white border border-gray-300 shadow-sm placeholder-gray-400 resize-y min-h-[100px] focus:outline-none focus:ring-2 focus:ring-brand-primary-500 focus:border-brand-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-bordered {
|
||||||
|
@apply border border-gray-300;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import AddGuestFromGuestBook from './AddGuestFromGuestBook'
|
import AddGuestFromGuestBook from './AddGuestFromGuestBook'
|
||||||
|
import EventNotesEditor from './EventNotesEditor'
|
||||||
|
|
||||||
interface Creator {
|
interface Creator {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,6 +22,7 @@ interface EventData {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
creator: Creator
|
creator: Creator
|
||||||
guests: any[]
|
guests: any[]
|
||||||
|
notes?: string
|
||||||
eventGuests: any[]
|
eventGuests: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +169,7 @@ export default function EventInfoDisplay({ event }: Props) {
|
|||||||
<p>{formatDate(event.createdAt)}</p>
|
<p>{formatDate(event.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<button
|
<button
|
||||||
@@ -179,6 +181,13 @@ export default function EventInfoDisplay({ event }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className='col-span-6'>
|
||||||
|
<EventNotesEditor
|
||||||
|
eventId={event.id}
|
||||||
|
initialNotes={event.notes || ''}
|
||||||
|
canEdit={['COUPLE', 'PLANNER'].includes(event.creator.role)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='col-span-3'>
|
<div className='col-span-3'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
|
|||||||
103
components/EventNotesEditor.tsx
Normal file
103
components/EventNotesEditor.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import InfoIcon from './icons/InfoIcon'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventId: string
|
||||||
|
initialNotes: string
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventNotesEditor({ eventId, initialNotes, canEdit }: Props) {
|
||||||
|
const [notes, setNotes] = useState(initialNotes || '');
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
const isAuthorized =
|
||||||
|
session?.user?.role === 'COUPLE' || session?.user?.role === 'PLANNER'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && textareaRef.current) {
|
||||||
|
textareaRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isEditing])
|
||||||
|
|
||||||
|
async function saveNotes() {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${eventId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notes }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to save notes')
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">Event Notes</h3>
|
||||||
|
<div className='prose prose-sm dark:prose-invertse'>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{notes || '_No notes yet._'}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (notes.trim() !== initialNotes?.trim()) {
|
||||||
|
saveNotes()
|
||||||
|
} else {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-semibold mb-1 flex gap-1 items-center'>
|
||||||
|
Notes
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="prose prose-brand rounded-lg textarea-bordered p-4 w-full min-h-[120px] cursor-text"
|
||||||
|
onClick={() => {
|
||||||
|
if (isAuthorized) setIsEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
rows={6}
|
||||||
|
className="textarea textarea-bordered w-full resize-none"
|
||||||
|
/>
|
||||||
|
) : notes.trim() ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{notes}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 italic textarea-bordered rounded-lg min-h-32 p-4">Click to add notes...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saving && <p className="text-xs text-gray-400">Saving...</p>}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs font-normal'>
|
||||||
|
<a href={'https://www.markdownguide.org/cheat-sheet/'} target='_blank' className='flex items-center underline'>
|
||||||
|
Supports Markdown
|
||||||
|
<InfoIcon />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
components/icons/InfoIcon.tsx
Normal file
9
components/icons/InfoIcon.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function InfoIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export const mutations = {
|
|||||||
|
|
||||||
async updateEvent(
|
async updateEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
data: Partial<{ name: string; date: string; location: string }>
|
data: Partial<{ name: string; date: string; location: string; notes?: string; }>
|
||||||
) {
|
) {
|
||||||
const { date, ...rest } = data;
|
const { date, ...rest } = data;
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ export const mutations = {
|
|||||||
const event = await prisma.event.update({
|
const event = await prisma.event.update({
|
||||||
where: { id: eventId },
|
where: { id: eventId },
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
...(parsedDate ? { date: parsedDate } : {}),
|
...(parsedDate ? { date: parsedDate } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const queries = {
|
|||||||
eventGuests: {
|
eventGuests: {
|
||||||
include: {
|
include: {
|
||||||
guestBookEntry: true,
|
guestBookEntry: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"prisma": "^6.10.1",
|
"prisma": "^6.10.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "notes" TEXT;
|
||||||
@@ -33,6 +33,7 @@ model Event {
|
|||||||
creatorId String
|
creatorId String
|
||||||
guests Guest[]
|
guests Guest[]
|
||||||
eventGuests EventGuest[]
|
eventGuests EventGuest[]
|
||||||
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user