399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
"use client"
|
|
|
|
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 } from '@/components/ui/card'
|
|
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 { 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'
|
|
|
|
export default function HomePage() {
|
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
|
|
// Form fields
|
|
const [title, setTitle] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [location, setLocation] = useState('')
|
|
const [url, setUrl] = useState('')
|
|
const [start, setStart] = useState('')
|
|
const [end, setEnd] = useState('')
|
|
const [allDay, setAllDay] = useState(false)
|
|
const [recurrenceRule, setRecurrenceRule] = useState<string | undefined>(undefined)
|
|
|
|
// AI
|
|
const [aiPrompt, setAiPrompt] = useState('')
|
|
const [aiLoading, setAiLoading] = useState(false)
|
|
const [summary, setSummary] = useState<string | null>(null)
|
|
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const stored = await getAllEvents()
|
|
setEvents(stored)
|
|
})()
|
|
}, [])
|
|
|
|
const { data: session, status } = useSession()
|
|
|
|
const resetForm = () => {
|
|
setTitle('')
|
|
setDescription('')
|
|
setLocation('')
|
|
setUrl('')
|
|
setStart('')
|
|
setEnd('')
|
|
setAllDay(false)
|
|
setEditingId(null)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
const eventData: CalendarEvent = {
|
|
id: editingId || nanoid(),
|
|
title,
|
|
description,
|
|
location,
|
|
url,
|
|
recurrenceRule,
|
|
start,
|
|
end: end || undefined,
|
|
allDay,
|
|
createdAt: editingId
|
|
? events.find(e => e.id === editingId)?.createdAt
|
|
: new Date().toISOString(),
|
|
lastModified: new Date().toISOString(),
|
|
}
|
|
if (editingId) {
|
|
await updateEvent(eventData)
|
|
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
|
|
} else {
|
|
await addEvent(eventData)
|
|
setEvents(prev => [...prev, eventData])
|
|
}
|
|
resetForm()
|
|
setDialogOpen(false)
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
await deleteEvent(id)
|
|
setEvents(prev => prev.filter(e => e.id !== id))
|
|
}
|
|
|
|
const handleClearAll = async () => {
|
|
await clearEvents()
|
|
setEvents([])
|
|
}
|
|
|
|
const handleImport = async (file: File) => {
|
|
const text = await file.text()
|
|
const parsed = parseICS(text)
|
|
for (const ev of parsed) {
|
|
await addEvent(ev)
|
|
}
|
|
const stored = await getAllEvents()
|
|
setEvents(stored)
|
|
}
|
|
|
|
const handleExport = () => {
|
|
const icsData = generateICS(events)
|
|
const blob = new Blob([icsData], { type: 'text/calendar' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `icallocal-export-${new Date().toLocaleTimeString()}.ics`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
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
|
|
setAiLoading(true)
|
|
|
|
const promise = (): Promise<{ message: string }> => new Promise(async (resolve, reject) => {
|
|
try {
|
|
const res = await fetch('/api/ai-event', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt: aiPrompt })
|
|
})
|
|
|
|
if (res.status === 401) {
|
|
setAiLoading(false)
|
|
reject({
|
|
message: 'Please sign in to use AI features.'
|
|
})
|
|
return
|
|
}
|
|
|
|
const data = await res.json()
|
|
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
if (data.length === 1) {
|
|
// Prefill dialog directly (same as before)
|
|
const ev = data[0]
|
|
setTitle(ev.title || '')
|
|
setDescription(ev.description || '')
|
|
setLocation(ev.location || '')
|
|
setUrl(ev.url || '')
|
|
setStart(ev.start || '')
|
|
setEnd(ev.end || '')
|
|
setAllDay(ev.allDay || false)
|
|
setEditingId(null)
|
|
setAiPrompt("")
|
|
setDialogOpen(true)
|
|
setRecurrenceRule(ev.recurrenceRule || undefined)
|
|
resolve({
|
|
message: 'Event has been created!'
|
|
})
|
|
|
|
} else {
|
|
// Save them all directly to DB
|
|
for (const ev of data) {
|
|
const newEvent = {
|
|
id: nanoid(),
|
|
...ev,
|
|
createdAt: new Date().toISOString(),
|
|
lastModified: new Date().toISOString(),
|
|
}
|
|
await addEvent(newEvent)
|
|
}
|
|
const stored = await getAllEvents()
|
|
setEvents(stored)
|
|
setAiPrompt("")
|
|
setSummary(`Added ${data.length} AI-generated events.`)
|
|
setSummaryUpdated(new Date().toLocaleString())
|
|
resolve({
|
|
message: 'Event has been created!'
|
|
})
|
|
}
|
|
} else {
|
|
reject({
|
|
message: 'AI did not return event data.'
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error(err)
|
|
reject({
|
|
message: 'Error from AI service.'
|
|
})
|
|
}
|
|
})
|
|
|
|
toast.promise(promise, {
|
|
loading: "Generating event...",
|
|
success: ({ message }) => {
|
|
return message
|
|
},
|
|
error: ({ message }) => {
|
|
return message
|
|
}
|
|
})
|
|
|
|
setAiLoading(false)
|
|
}
|
|
|
|
// AI Summarize Events
|
|
const handleAiSummarize = async () => {
|
|
if (!events.length) {
|
|
setSummary("No events to summarize.")
|
|
setSummaryUpdated(new Date().toLocaleString())
|
|
return
|
|
}
|
|
setAiLoading(true)
|
|
try {
|
|
const res = await fetch('/api/ai-summary', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ events })
|
|
})
|
|
const data = await res.json()
|
|
if (data.summary) {
|
|
setSummary(data.summary)
|
|
setSummaryUpdated(new Date().toLocaleString())
|
|
} else {
|
|
setSummary("No summary generated.")
|
|
setSummaryUpdated(new Date().toLocaleString())
|
|
}
|
|
} catch {
|
|
setSummary("Error summarizing events")
|
|
setSummaryUpdated(new Date().toLocaleString())
|
|
} finally {
|
|
setAiLoading(false)
|
|
}
|
|
}
|
|
|
|
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'
|
|
}`}
|
|
>
|
|
{/* 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 AI natural language event creation
|
|
</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 && <p className="text-gray-500 italic">No events yet</p>}
|
|
<ul className="space-y-2">
|
|
{events.map(ev => (
|
|
<li key={ev.id} className="p-3 border rounded flex justify-between items-start">
|
|
<div>
|
|
<div className="font-semibold">{ev.title}</div>
|
|
{ev.recurrenceRule && (
|
|
<div className="text-xs text-blue-600 mt-1">
|
|
Repeats: {ev.recurrenceRule}
|
|
</div>
|
|
)}
|
|
<div className="text-sm text-gray-500">
|
|
{ev.allDay ? ev.start.split('T')[0] : new Date(ev.start).toLocaleString()}
|
|
{ev.location && <span> @ {ev.location}</span>}
|
|
</div>
|
|
{ev.description && <div className="text-sm mt-1 wrap-anywhere">{ev.description}</div>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={() => {
|
|
setTitle(ev.title)
|
|
setDescription(ev.description || '')
|
|
setLocation(ev.location || '')
|
|
setUrl(ev.url || '')
|
|
setStart(ev.start)
|
|
setEnd(ev.end || '')
|
|
setAllDay(ev.allDay || false)
|
|
setEditingId(ev.id)
|
|
setDialogOpen(true)
|
|
}}>Edit</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => handleDelete(ev.id)}>Delete</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
|
|
{/* 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 >
|
|
)
|
|
}
|