diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx index 6a493a3..e638e58 100644 --- a/src/app/auth/error/page.tsx +++ b/src/app/auth/error/page.tsx @@ -1,43 +1,45 @@ -"use client" +"use client"; -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import Link from "next/link" -import { useSearchParams } from "next/navigation" -import { Suspense } from "react" +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; function Search() { - const searchParams = useSearchParams() - const errorMessage = searchParams.get('error') - - // Sanitize error message to prevent XSS - const sanitizedError = errorMessage - ? errorMessage.replace(/[<>]/g, '') - : 'An authentication error occurred' + const searchParams = useSearchParams(); + const errorMessage = searchParams.get("error"); - return (
- {sanitizedError} -
) + // Sanitize error message to prevent XSS + const sanitizedError = errorMessage + ? errorMessage.replace(/[<>]/g, "") + : "An authentication error occurred"; + + return ( +
+ {sanitizedError} +
+ ); } export default function AuthErrorPage() { - return ( -
- - - Error - - - - - -
- -
-
-
-
- ) + return ( +
+ + + Error + + + + + +
+ +
+
+
+
+ ); } diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 780a8bd..60b08ec 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,67 +1,81 @@ -"use client" +"use client"; -import { signIn, useSession } from "@/lib/auth-client" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { useEffect, useState } from "react" -import { toast } from "sonner" +import { signIn, useSession } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; export default function SignInPage() { - const { data: session, isPending } = useSession() - const router = useRouter() - const [isLoading, setIsLoading] = useState(false) + const { data: session, isPending } = useSession(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - if (session?.user) { - router.push("/") - } - }, [session, router]) + useEffect(() => { + if (session?.user) { + router.push("/"); + } + }, [session, router]); - const handleSignIn = async () => { - setIsLoading(true) - try { - await signIn.oauth2({ - providerId: "authentik", - callbackURL: "/", - }) - } catch (_error) { - toast.error("Failed to sign in. Please try again.") - } finally { - setIsLoading(false) - } - } + const handleSignIn = async () => { + setIsLoading(true); + try { + await signIn.oauth2({ + providerId: "authentik", + callbackURL: "/", + }); + } catch (_error) { + toast.error("Failed to sign in. Please try again."); + } finally { + setIsLoading(false); + } + }; - if (isPending) { - return null - } + if (isPending) { + return null; + } - if (session?.user) { - return null - } + if (session?.user) { + return null; + } - return ( -
- - - Welcome - - Sign in to access AI-powered calendar features - - - - + return ( +
+ + + Welcome + + Sign in to access AI-powered calendar features + + + + -
- - Continue without signing in - -
-
-
-
- ) +
+ + Continue without signing in + +
+
+
+
+ ); } diff --git a/src/app/auth/signout/page.tsx b/src/app/auth/signout/page.tsx index 17fc7a1..02f8636 100644 --- a/src/app/auth/signout/page.tsx +++ b/src/app/auth/signout/page.tsx @@ -1,57 +1,69 @@ -"use client" +"use client"; -import { signOut, useSession } from "@/lib/auth-client" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { signOut, useSession } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function SignOutPage() { - const { data: session, isPending } = useSession() - const router = useRouter() + const { data: session, isPending } = useSession(); + const router = useRouter(); - useEffect(() => { - if (!session?.user) { - router.push("/") - } - }, [session, router]) + useEffect(() => { + if (!session?.user) { + router.push("/"); + } + }, [session, router]); - const handleSignOut = async () => { - await signOut() - router.push("/") - } + const handleSignOut = async () => { + await signOut(); + router.push("/"); + }; - if (isPending || !session?.user) { - return null - } + if (isPending || !session?.user) { + return null; + } - return ( -
- - - Sign Out - - Are you sure you want to sign out? - - - -
-
Currently signed in as
-
{session.user?.name || session.user?.email}
-
+ return ( +
+ + + Sign Out + Are you sure you want to sign out? + + +
+
+ Currently signed in as +
+
+ {session.user?.name || session.user?.email} +
+
-
- +
+ - -
- - -
- ) + +
+
+
+
+ ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 31963c4..9d2e23a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,49 +5,56 @@ import { ThemeProvider } from "next-themes"; import { ModeToggle } from "@/components/mode-toggle"; import SignIn from "@/components/sign-in"; import { Toaster } from "@/components/ui/sonner"; -import Link from "next/link" +import Link from "next/link"; -const geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" }) +const geist = Geist({ + subsets: ["latin", "cyrillic"], + variable: "--font-geist-sans", +}); -const magra = Magra({ subsets: ["latin"], weight: "400", variable: "--font-cascadia-code" }) +const magra = Magra({ + subsets: ["latin"], + weight: "400", + variable: "--font-cascadia-code", +}); export const metadata: Metadata = { - title: 'Local iCal', - description: 'Local iCal editor for calendar events', - creator: "Dmytro Stanchiev", -} + title: "Local iCal", + description: "Local iCal editor for calendar events", + creator: "Dmytro Stanchiev", +}; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - -
- -

- {metadata.title as string || "iCal PWA"} -

- -
- - -
-
-
{children}
- -
- - - ); + return ( + + + +
+ +

+ {(metadata.title as string) || "iCal PWA"} +

+ +
+ + +
+
+
{children}
+ +
+ + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3fdf1bc..d063951 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,309 +1,315 @@ -"use client" +"use client"; -import { useEffect, useState } from 'react' -import { nanoid } from 'nanoid' -import { useSession } from '@/lib/auth-client' -import { toast } from 'sonner' +import { useEffect, useState } from "react"; +import { nanoid } from "nanoid"; +import { useSession } from "@/lib/auth-client"; +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 { + 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 { 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' +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([]) - const [dialogOpen, setDialogOpen] = useState(false) - const [editingId, setEditingId] = useState(null) - const [isDragOver, setIsDragOver] = useState(false) + const [events, setEvents] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(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(undefined) + // 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( + undefined, + ); - // AI - const [aiPrompt, setAiPrompt] = useState('') - const [aiLoading, setAiLoading] = useState(false) - const [summary, setSummary] = useState(null) - const [summaryUpdated, setSummaryUpdated] = useState(null) + // AI + const [aiPrompt, setAiPrompt] = useState(""); + const [aiLoading, setAiLoading] = useState(false); + const [summary, setSummary] = useState(null); + const [summaryUpdated, setSummaryUpdated] = useState(null); - useEffect(() => { - (async () => { - const stored = await getAllEvents() - setEvents(stored) - })() - }, []) + useEffect(() => { + (async () => { + const stored = await getAllEvents(); + setEvents(stored); + })(); + }, []); - const { data: session, isPending } = useSession() + const { data: session, isPending } = useSession(); - const resetForm = () => { - setTitle('') - setDescription('') - setLocation('') - setUrl('') - setStart('') - setEnd('') - setAllDay(false) - setEditingId(null) - setRecurrenceRule(undefined) - } + const resetForm = () => { + setTitle(""); + setDescription(""); + setLocation(""); + setUrl(""); + setStart(""); + setEnd(""); + setAllDay(false); + setEditingId(null); + setRecurrenceRule(undefined); + }; - 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 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 handleDelete = async (id: string) => { + await deleteEvent(id); + setEvents((prev) => prev.filter((e) => e.id !== id)); + }; - const handleClearAll = async () => { - await clearEvents() - setEvents([]) - } + 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 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) - } + 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); + }; - // AI Create Event - const handleAiCreate = async () => { - if (!aiPrompt.trim()) return - setAiLoading(true) + // 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 }) - }) + 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 - } + if (res.status === 401) { + setAiLoading(false); + reject({ + message: "Please sign in to use AI features.", + }); + return; + } - const data = await res.json() + 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!' - }) + 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.", + }); + } + }); - } 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; + }, + }); - toast.promise(promise, { - loading: "Generating event...", - success: ({ message }) => { - return message - }, - error: ({ message }) => { - return message - } - }) + setAiLoading(false); + }; - 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); + } + }; - // 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) - } - } + 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); + }; - 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 ( + + - return ( - - + setDialogOpen(true)} + onImport={handleImport} + onExport={handleExport} + onClearAll={handleClearAll} + /> - setDialogOpen(true)} - onImport={handleImport} - onExport={handleExport} - onClearAll={handleClearAll} - /> + - - - - - ) + + + ); }