Refactor event management into reusable components

- Extract EventCard, EventsList, and event dialog into separate components
- Add new AI toolbar and drag-drop container components
- Simplify main page.tsx by removing inline component definitions
- Improve code organization and maintainability
This commit is contained in:
2025-08-22 12:33:07 -04:00
parent 6ab2946e8a
commit eb73f9f929
7 changed files with 464 additions and 248 deletions

View File

@@ -2,127 +2,18 @@
import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { LucideMapPin, Clock, MoreHorizontal, Calendar1Icon } from 'lucide-react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { RecurrencePicker } from '@/components/recurrence-picker'
import { IcsFilePicker } from '@/components/ics-file-picker'
import { useSession } from 'next-auth/react'
import { toast } from 'sonner'
import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
import { parseICS, generateICS } from '@/lib/ical'
import type { CalendarEvent } from '@/lib/types'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from 'next-auth/react'
import { toast } from 'sonner'
// Individual event card component
const EventCard = ({ event, onEdit, onDelete }: { event: CalendarEvent, onEdit: (event: CalendarEvent) => void, onDelete: (eventId: string) => void }) => {
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
return allDay
? new Date(dateStr).toLocaleDateString()
: new Date(dateStr).toLocaleString()
}
const handleEdit = () => {
onEdit({
id: event.id,
title: event.title,
description: event.description || '',
location: event.location || '',
url: event.url || '',
start: event.start,
end: event.end || '',
allDay: event.allDay || false
})
}
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<h3 className="font-semibold leading-none tracking-tight">
{event.title}
</h3>
{event.recurrenceRule && (
<Badge variant="secondary" className="text-xs">
Repeats: {event.recurrenceRule}
</Badge>
)}
{event.description && (
<p className="text-sm text-muted-foreground mt-2 break-words">
{event.description}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
Edit event
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(event.id)}
className="text-destructive"
>
Delete event
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{formatDateTime(event.start, event.allDay)}
</div>
{event.location && (
<div className="flex items-center text-sm text-muted-foreground">
<LucideMapPin className="mr-2 h-4 w-4" />
{event.location}
</div>
)}
</div>
</CardContent>
</Card>
)
}
const EventsList = ({ events, onEdit, onDelete }: { events: CalendarEvent[], onEdit: (event: CalendarEvent) => void, onDelete: (eventId: string) => void }) => {
if (events.length === 0) {
return (<div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' />
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3>
<p className="text-sm text-muted-foreground">Create your first event to get started</p>
</div>)
}
return (
<div className="space-y-4">
{events.map(event => (
<EventCard
key={event.id}
event={event}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)
}
import { AIToolbar } from '@/components/ai-toolbar'
import { EventActionsToolbar } from '@/components/event-actions-toolbar'
import { EventsList } from '@/components/events-list'
import { EventDialog } from '@/components/event-dialog'
import { DragDropContainer } from '@/components/drag-drop-container'
export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([])
@@ -164,6 +55,7 @@ export default function HomePage() {
setEnd('')
setAllDay(false)
setEditingId(null)
setRecurrenceRule(undefined)
}
const handleSave = async () => {
@@ -226,22 +118,6 @@ export default function HomePage() {
URL.revokeObjectURL(url)
}
// Drag-and-drop
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) }
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false) }
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0]
if (file.name.endsWith('.ics')) {
handleImport(file)
} else {
toast.warning('Please drop an .ics file')
}
}
}
// AI Create Event
const handleAiCreate = async () => {
if (!aiPrompt.trim()) return
@@ -360,130 +236,74 @@ export default function HomePage() {
}
}
const handleEdit = (eventData: CalendarEvent) => {
setTitle(eventData.title)
setDescription(eventData.description || "")
setLocation(eventData.location || "")
setUrl(eventData.url || "")
setStart(eventData.start)
setEnd(eventData.end || "")
setAllDay(eventData.allDay || false)
setEditingId(eventData.id)
setRecurrenceRule(eventData.recurrenceRule)
setDialogOpen(true)
}
return (
<div onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-700'
}`}
<DragDropContainer
isDragOver={isDragOver}
setIsDragOver={setIsDragOver}
onImport={handleImport}
>
{/* AI Toolbar */}
{status === "loading" ? <div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div> : <div>
{session?.user ? (
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
<div className='w-full'>
<Textarea
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
// Band-aid for scrollbar clipping out of the box
style={{ clipPath: "inset(0 round 1rem)" }}
placeholder='Describe event for AI to create'
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
</div>
<div className='flex flex-row gap-2 pt-1'>
<Button onClick={handleAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
</div>
</div>
) : (
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
<div className="text-sm text-muted-foreground">
Sign in to unlock natural language event creation powered by AI
</div>
</div>
)}
</div>}
<AIToolbar
session={session}
status={status}
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
onAiCreate={handleAiCreate}
onAiSummarize={handleAiSummarize}
summary={summary}
summaryUpdated={summaryUpdated}
/>
<EventActionsToolbar
events={events}
onAddEvent={() => setDialogOpen(true)}
onImport={handleImport}
onExport={handleExport}
onClearAll={handleClearAll}
/>
{/* Summary Panel */}
{
summary && (
<Card className="p-4 mb-4">
<div className="text-sm mb-1">
Summary updated {summaryUpdated}
</div>
<div>{summary}</div>
</Card>
)
}
{/* AI Actions Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p>
<div className="gap-2 mb-4">
<Button variant="secondary" onClick={handleAiSummarize} disabled={aiLoading}>
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
</Button>
</div>
{/* Control Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p>
<div className="flex flex-wrap gap-2 mb-4">
<Button onClick={() => setDialogOpen(true)}>Add Event</Button>
<IcsFilePicker onFileSelect={handleImport} variant='secondary'>Import .ics</IcsFilePicker>
{events.length > 0 && (
<>
<Button variant="secondary" onClick={handleExport}>Export .ics</Button>
<Button variant="destructive" onClick={handleClearAll}>Clear All</Button>
</>
)}
</div>
{/* Event List */}
{events.length === 0 && (
<>
</>)}
<EventsList
events={events}
onEdit={(eventData) => {
setTitle(eventData.title)
setDescription(eventData.description || "")
setLocation(eventData.location || "")
setUrl(eventData.url || "")
setStart(eventData.start)
setEnd(eventData.end || "")
setAllDay(eventData.allDay || false)
setEditingId(eventData.id)
setDialogOpen(true)
}}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
</DialogHeader>
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
<textarea className="border rounded p-2 w-full" placeholder="Description"
value={description} onChange={e => setDescription(e.target.value)} />
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
<label className="flex items-center gap-2 mt-2">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
All day event
</label>
{!allDay ? (
<>
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
</>
) : (
<>
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} />
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} />
</>
)}
<DialogFooter>
<Button onClick={handleSave}>{editingId ? 'Update' : 'Save'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className='mt-auto w-full pb-4 text-gray-400'>
<div className='max-w-fit m-auto'> Drag & Drop *.ics here</div>
</div>
</div >
<EventDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingId={editingId}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
location={location}
setLocation={setLocation}
url={url}
setUrl={setUrl}
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allDay={allDay}
setAllDay={setAllDay}
recurrenceRule={recurrenceRule}
setRecurrenceRule={setRecurrenceRule}
onSave={handleSave}
onReset={resetForm}
/>
</DragDropContainer>
)
}

View File

@@ -0,0 +1,83 @@
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card } from '@/components/ui/card'
import { Session } from 'next-auth'
interface AIToolbarProps {
session: Session | null
status: 'loading' | 'authenticated' | 'unauthenticated'
aiPrompt: string
setAiPrompt: (prompt: string) => void
aiLoading: boolean
onAiCreate: () => void
onAiSummarize: () => void
summary: string | null
summaryUpdated: string | null
}
export const AIToolbar = ({
session,
status,
aiPrompt,
setAiPrompt,
aiLoading,
onAiCreate,
onAiSummarize,
summary,
summaryUpdated
}: AIToolbarProps) => {
return (
<>
{/* AI Toolbar */}
{status === "loading" ? (
<div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div>
) : (
<div>
{session?.user ? (
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
<div className='w-full'>
<Textarea
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
// Band-aid for scrollbar clipping out of the box
style={{ clipPath: "inset(0 round 1rem)" }}
placeholder='Describe event for AI to create'
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
</div>
<div className='flex flex-row gap-2 pt-1'>
<Button onClick={onAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
</div>
</div>
) : (
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
<div className="text-sm text-muted-foreground">
Sign in to unlock natural language event creation powered by AI
</div>
</div>
)}
</div>
)}
{/* Summary Panel */}
{summary && (
<Card className="p-4 mb-4">
<div className="text-sm mb-1">
Summary updated {summaryUpdated}
</div>
<div>{summary}</div>
</Card>
)}
{/* AI Actions Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p>
<div className="gap-2 mb-4">
<Button variant="secondary" onClick={onAiSummarize} disabled={aiLoading}>
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
</Button>
</div>
</>
)
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react'
import { toast } from 'sonner'
interface DragDropContainerProps {
children: ReactNode
isDragOver: boolean
setIsDragOver: (isDragOver: boolean) => void
onImport: (file: File) => void
}
export const DragDropContainer = ({
children,
isDragOver,
setIsDragOver,
onImport
}: DragDropContainerProps) => {
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0]
if (file.name.endsWith('.ics')) {
onImport(file)
} else {
toast.warning('Please drop an .ics file')
}
}
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-700'
}`}
>
{children}
<div className='mt-auto w-full pb-4 text-gray-400'>
<div className='max-w-fit m-auto'>Drag & Drop *.ics here</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button'
import { IcsFilePicker } from '@/components/ics-file-picker'
import type { CalendarEvent } from '@/lib/types'
interface EventActionsToolbarProps {
events: CalendarEvent[]
onAddEvent: () => void
onImport: (file: File) => void
onExport: () => void
onClearAll: () => void
}
export const EventActionsToolbar = ({
events,
onAddEvent,
onImport,
onExport,
onClearAll
}: EventActionsToolbarProps) => {
return (
<>
{/* Control Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p>
<div className="flex flex-wrap gap-2 mb-4">
<Button onClick={onAddEvent}>Add Event</Button>
<IcsFilePicker onFileSelect={onImport} variant='secondary'>Import .ics</IcsFilePicker>
{events.length > 0 && (
<>
<Button variant="secondary" onClick={onExport}>Export .ics</Button>
<Button variant="destructive" onClick={onClearAll}>Clear All</Button>
</>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import type { CalendarEvent } from '@/lib/types'
interface EventCardProps {
event: CalendarEvent
onEdit: (event: CalendarEvent) => void
onDelete: (eventId: string) => void
}
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
return allDay
? new Date(dateStr).toLocaleDateString()
: new Date(dateStr).toLocaleString()
}
const handleEdit = () => {
onEdit({
id: event.id,
title: event.title,
description: event.description || '',
location: event.location || '',
url: event.url || '',
start: event.start,
end: event.end || '',
allDay: event.allDay || false
})
}
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<h3 className="font-semibold leading-none tracking-tight">
{event.title}
</h3>
{event.recurrenceRule && (
<Badge variant="secondary" className="text-xs">
Repeats: {event.recurrenceRule}
</Badge>
)}
{event.description && (
<p className="text-sm text-muted-foreground mt-2 break-words">
{event.description}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
Edit event
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(event.id)}
className="text-destructive"
>
Delete event
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{formatDateTime(event.start, event.allDay)}
</div>
{event.location && (
<div className="flex items-center text-sm text-muted-foreground">
<LucideMapPin className="mr-2 h-4 w-4" />
{event.location}
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,96 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { RecurrencePicker } from '@/components/recurrence-picker'
interface EventDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editingId: string | null
title: string
setTitle: (title: string) => void
description: string
setDescription: (description: string) => void
location: string
setLocation: (location: string) => void
url: string
setUrl: (url: string) => void
start: string
setStart: (start: string) => void
end: string
setEnd: (end: string) => void
allDay: boolean
setAllDay: (allDay: boolean) => void
recurrenceRule: string | undefined
setRecurrenceRule: (rule: string | undefined) => void
onSave: () => void
onReset: () => void
}
export const EventDialog = ({
open,
onOpenChange,
editingId,
title,
setTitle,
description,
setDescription,
location,
setLocation,
url,
setUrl,
start,
setStart,
end,
setEnd,
allDay,
setAllDay,
recurrenceRule,
setRecurrenceRule,
onSave,
onReset
}: EventDialogProps) => {
const handleOpenChange = (val: boolean) => {
if (!val) onReset()
onOpenChange(val)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
</DialogHeader>
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
<textarea
className="border rounded p-2 w-full"
placeholder="Description"
value={description}
onChange={e => setDescription(e.target.value)}
/>
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
<label className="flex items-center gap-2 mt-2">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
All day event
</label>
{!allDay ? (
<>
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
</>
) : (
<>
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} />
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} />
</>
)}
<DialogFooter>
<Button onClick={onSave}>{editingId ? 'Update' : 'Save'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,34 @@
import { Calendar1Icon } from 'lucide-react'
import { EventCard } from './event-card'
import type { CalendarEvent } from '@/lib/types'
interface EventsListProps {
events: CalendarEvent[]
onEdit: (event: CalendarEvent) => void
onDelete: (eventId: string) => void
}
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' />
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3>
<p className="text-sm text-muted-foreground">Create your first event to get started</p>
</div>
)
}
return (
<div className="space-y-4">
{events.map(event => (
<EventCard
key={event.id}
event={event}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)
}