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:
310
src/app/page.tsx
310
src/app/page.tsx
@@ -2,127 +2,18 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { Button } from '@/components/ui/button'
|
import { useSession } from 'next-auth/react'
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { toast } from 'sonner'
|
||||||
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 { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
|
import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
|
||||||
import { parseICS, generateICS } from '@/lib/ical'
|
import { parseICS, generateICS } from '@/lib/ical'
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
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
|
import { AIToolbar } from '@/components/ai-toolbar'
|
||||||
const EventCard = ({ event, onEdit, onDelete }: { event: CalendarEvent, onEdit: (event: CalendarEvent) => void, onDelete: (eventId: string) => void }) => {
|
import { EventActionsToolbar } from '@/components/event-actions-toolbar'
|
||||||
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
|
import { EventsList } from '@/components/events-list'
|
||||||
return allDay
|
import { EventDialog } from '@/components/event-dialog'
|
||||||
? new Date(dateStr).toLocaleDateString()
|
import { DragDropContainer } from '@/components/drag-drop-container'
|
||||||
: 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
@@ -164,6 +55,7 @@ export default function HomePage() {
|
|||||||
setEnd('')
|
setEnd('')
|
||||||
setAllDay(false)
|
setAllDay(false)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
|
setRecurrenceRule(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -226,22 +118,6 @@ export default function HomePage() {
|
|||||||
URL.revokeObjectURL(url)
|
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
|
// AI Create Event
|
||||||
const handleAiCreate = async () => {
|
const handleAiCreate = async () => {
|
||||||
if (!aiPrompt.trim()) return
|
if (!aiPrompt.trim()) return
|
||||||
@@ -360,81 +236,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleEdit = (eventData: CalendarEvent) => {
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* 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>}
|
|
||||||
|
|
||||||
|
|
||||||
{/* 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)
|
setTitle(eventData.title)
|
||||||
setDescription(eventData.description || "")
|
setDescription(eventData.description || "")
|
||||||
setLocation(eventData.location || "")
|
setLocation(eventData.location || "")
|
||||||
@@ -443,47 +245,65 @@ export default function HomePage() {
|
|||||||
setEnd(eventData.end || "")
|
setEnd(eventData.end || "")
|
||||||
setAllDay(eventData.allDay || false)
|
setAllDay(eventData.allDay || false)
|
||||||
setEditingId(eventData.id)
|
setEditingId(eventData.id)
|
||||||
|
setRecurrenceRule(eventData.recurrenceRule)
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContainer
|
||||||
|
isDragOver={isDragOver}
|
||||||
|
setIsDragOver={setIsDragOver}
|
||||||
|
onImport={handleImport}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventsList
|
||||||
|
events={events}
|
||||||
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add/Edit Dialog */}
|
<EventDialog
|
||||||
<Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}>
|
open={dialogOpen}
|
||||||
<DialogContent>
|
onOpenChange={setDialogOpen}
|
||||||
<DialogHeader>
|
editingId={editingId}
|
||||||
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
|
title={title}
|
||||||
</DialogHeader>
|
setTitle={setTitle}
|
||||||
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
description={description}
|
||||||
<textarea className="border rounded p-2 w-full" placeholder="Description"
|
setDescription={setDescription}
|
||||||
value={description} onChange={e => setDescription(e.target.value)} />
|
location={location}
|
||||||
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
|
setLocation={setLocation}
|
||||||
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
|
url={url}
|
||||||
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
|
setUrl={setUrl}
|
||||||
|
start={start}
|
||||||
<label className="flex items-center gap-2 mt-2">
|
setStart={setStart}
|
||||||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
end={end}
|
||||||
All day event
|
setEnd={setEnd}
|
||||||
</label>
|
allDay={allDay}
|
||||||
{!allDay ? (
|
setAllDay={setAllDay}
|
||||||
<>
|
recurrenceRule={recurrenceRule}
|
||||||
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
|
setRecurrenceRule={setRecurrenceRule}
|
||||||
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
|
onSave={handleSave}
|
||||||
</>
|
onReset={resetForm}
|
||||||
) : (
|
/>
|
||||||
<>
|
</DragDropContainer>
|
||||||
<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 >
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
83
src/components/ai-toolbar.tsx
Normal file
83
src/components/ai-toolbar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/components/drag-drop-container.tsx
Normal file
55
src/components/drag-drop-container.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/event-actions-toolbar.tsx
Normal file
36
src/components/event-actions-toolbar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/components/event-card.tsx
Normal file
92
src/components/event-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/components/event-dialog.tsx
Normal file
96
src/components/event-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/events-list.tsx
Normal file
34
src/components/events-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user