phase 3 - ical import/export

This commit is contained in:
2025-08-14 23:47:19 -04:00
parent 6321c1f7b1
commit e2fc1d7723
4 changed files with 101 additions and 4 deletions

View File

@@ -8,6 +8,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ical.js": "^2.2.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
@@ -851,6 +852,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

View File

@@ -13,6 +13,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ical.js": "^2.2.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { type CalendarEvent } from '@/lib/types' import { type CalendarEvent } from '@/lib/types'
import { addEvent, deleteEvent, getAllEvents, clearEvents } from '@/lib/db' import { addEvent, deleteEvent, getAllEvents, clearEvents } from '@/lib/db'
import { parseICS, generateICS } from '@/lib/ical'
export default function HomePage() { export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]) const [events, setEvents] = useState<CalendarEvent[]>([])
@@ -15,7 +16,6 @@ export default function HomePage() {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [start, setStart] = useState('') const [start, setStart] = useState('')
// Load events only in the browser
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const stored = await getAllEvents() const stored = await getAllEvents()
@@ -42,11 +42,61 @@ export default function HomePage() {
setEvents([]) setEvents([])
} }
// --- IMPORT ---
const handleImport = async (file: File) => {
const text = await file.text()
const parsed = parseICS(text)
// Save to DB and update state
for (const ev of parsed) {
await addEvent(ev)
}
const stored = await getAllEvents()
setEvents(stored)
}
// --- EXPORT ---
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 = 'events.ics'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return ( return (
<div> <div className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button onClick={() => setOpen(true)}>Add Event</Button> <Button onClick={() => setOpen(true)}>Add Event</Button>
{events.length > 0 && <Button variant="destructive" onClick={handleClearAll}>Clear All</Button>} {events.length > 0 && (
<>
<Button variant="secondary" onClick={handleExport}>
Export .ics
</Button>
<Button variant="destructive" onClick={handleClearAll}>
Clear All
</Button>
</>
)}
<label className="cursor-pointer">
<span className="px-3 py-2 bg-blue-500 text-white rounded">Import .ics</span>
<input
type="file"
accept=".ics"
hidden
onChange={e => {
if (e.target.files?.length) {
handleImport(e.target.files[0])
}
}}
/>
</label>
</div> </div>
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">

43
src/lib/ical.ts Normal file
View File

@@ -0,0 +1,43 @@
import ICAL from "ical.js";
import type { CalendarEvent } from "@/lib/types";
export function parseICS(icsString: string): CalendarEvent[] {
const jcalData = ICAL.parse(icsString);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents("vevent");
return vevents.map((v) => {
const ev = new ICAL.Event(v);
return {
id: ev.uid || crypto.randomUUID(),
title: ev.summary || "Untitled Event",
start: ev.startDate.toJSDate().toISOString().split("T")[0],
end: ev.endDate
? ev.endDate.toJSDate().toISOString().split("T")[0]
: undefined,
description: ev.description || "",
};
});
}
export function generateICS(events: CalendarEvent[]): string {
const comp = new ICAL.Component(["vcalendar", [], []]);
comp.addPropertyWithValue("version", "2.0");
comp.addPropertyWithValue("prodid", "-//YourAppName//EN");
events.forEach((ev) => {
const vevent = new ICAL.Component("vevent");
vevent.addPropertyWithValue("uid", ev.id);
vevent.addPropertyWithValue("summary", ev.title);
vevent.addPropertyWithValue("dtstart", ICAL.Time.fromDateString(ev.start));
if (ev.end) {
vevent.addPropertyWithValue("dtend", ICAL.Time.fromDateString(ev.end));
}
if (ev.description) {
vevent.addPropertyWithValue("description", ev.description);
}
comp.addSubcomponent(vevent);
});
return comp.toString();
}