diff --git a/package-lock.json b/package-lock.json index 3ffb4bc..3de0d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,11 +41,14 @@ "@supabase/auth-ui-shared": "^0.1.8", "@supabase/supabase-js": "^2.47.10", "@tanstack/react-query": "^5.56.2", + "baseline-browser-mapping": "^2.10.8", + "caniuse-lite": "^1.0.30001780", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", "dotenv": "^16.4.7", "embla-carousel-react": "^8.3.0", + "groq-sdk": "^1.1.1", "input-otp": "^1.2.4", "lucide-react": "^0.469.0", "next-themes": "^0.3.0", @@ -59,6 +62,7 @@ "sonner": "^1.7.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tesseract.js": "^7.0.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, @@ -3381,6 +3385,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3393,6 +3409,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3469,10 +3491,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001669", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", - "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", - "dev": true, + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "funding": [ { "type": "opencollective", @@ -4390,6 +4411,15 @@ "dev": true, "license": "MIT" }, + "node_modules/groq-sdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-1.1.1.tgz", + "integrity": "sha512-p0fb/sfrYfUDJf+b4s+ptV6XrQnY0gA0u2pbaH4E1l6yaeLCKtdX5OBhP/wXXBuKrSnU9w45RaFebfJDWxsXMw==", + "license": "Apache-2.0", + "bin": { + "groq-sdk": "bin/cli" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4412,6 +4442,12 @@ "node": ">= 0.4" } }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4543,6 +4579,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4817,6 +4859,26 @@ "react-dom": "^16.8 || ^17 || ^18" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -4861,6 +4923,15 @@ "node": ">= 6" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5864,6 +5935,36 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/tesseract.js/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6183,6 +6284,12 @@ } } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6358,6 +6465,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index bc7b1f9..f08b292 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "date-fns": "^3.6.0", "dotenv": "^16.4.7", "embla-carousel-react": "^8.3.0", + "groq-sdk": "^1.1.1", "input-otp": "^1.2.4", "lucide-react": "^0.469.0", "next-themes": "^0.3.0", @@ -64,6 +65,7 @@ "sonner": "^1.7.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tesseract.js": "^7.0.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, diff --git a/src/components/AddEventDialog.tsx b/src/components/AddEventDialog.tsx index 662dec5..dd209a5 100644 --- a/src/components/AddEventDialog.tsx +++ b/src/components/AddEventDialog.tsx @@ -13,6 +13,7 @@ interface AddEventDialogProps { onOpenChange: (open: boolean) => void; onSubmit: (event: Omit) => void; selectedDate: Date | undefined; + initialData?: Partial; } const CATEGORIES = [ @@ -23,7 +24,7 @@ const CATEGORIES = [ { value: "social", label: "Social" }, ]; -const AddEventDialog = ({ open, onOpenChange, onSubmit, selectedDate }: AddEventDialogProps) => { +const AddEventDialog = ({ open, onOpenChange, onSubmit, selectedDate, initialData }: AddEventDialogProps) => { const [title, setTitle] = useState(""); const [date, setDate] = useState(""); const [time, setTime] = useState(""); @@ -32,16 +33,27 @@ const AddEventDialog = ({ open, onOpenChange, onSubmit, selectedDate }: AddEvent const [recurrence, setRecurrence] = useState("none"); useEffect(() => { - if (selectedDate) { - const localDate = new Date( - selectedDate.getFullYear(), - selectedDate.getMonth(), - selectedDate.getDate() - ); - const formattedDate = localDate.toLocaleDateString('en-CA'); - setDate(formattedDate); + if (open) { + setTitle(initialData?.title || ""); + setTime(initialData?.time || ""); + setDescription(initialData?.description || ""); + setCategory(initialData?.category || ""); + setRecurrence(initialData?.recurrence || "none"); + + if (initialData?.date) { + setDate(initialData.date); + } else if (selectedDate) { + const localDate = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate() + ); + setDate(localDate.toLocaleDateString('en-CA')); + } else { + setDate(""); + } } - }, [selectedDate]); + }, [open, initialData, selectedDate]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/ImageUploadDialog.tsx b/src/components/ImageUploadDialog.tsx new file mode 100644 index 0000000..d699439 --- /dev/null +++ b/src/components/ImageUploadDialog.tsx @@ -0,0 +1,126 @@ +import { useState, useRef } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { parseEventFromAI, ParsedEvent } from "@/lib/ai"; +import { Image as ImageIcon, Loader2, UploadCloud } from "lucide-react"; + +interface ImageUploadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onParsed: (event: ParsedEvent) => void; +} + +const ImageUploadDialog = ({ open, onOpenChange, onParsed }: ImageUploadDialogProps) => { + const [isParsing, setIsParsing] = useState(false); + const [preview, setPreview] = useState(null); + const fileInputRef = useRef(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Check if image + if (!file.type.startsWith('image/')) { + toast.error("Please upload an image file."); + return; + } + + // Convert to base64 Data URL for Puter.js and preview + const reader = new FileReader(); + reader.onload = async (event) => { + const dataUrl = event.target?.result as string; + setPreview(dataUrl); + setIsParsing(true); + + try { + const parsedEvent = await parseEventFromAI(dataUrl, "image"); + if (parsedEvent) { + onParsed(parsedEvent); + onOpenChange(false); + setPreview(null); + } else { + toast.error("Could not find event details in the image."); + } + } catch (error) { + toast.error("Failed to parse image."); + } finally { + setIsParsing(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + reader.onerror = () => { + toast.error("Failed to read file."); + }; + + reader.readAsDataURL(file); + }; + + const handleReset = () => { + if (!isParsing) { + setPreview(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + return ( + { + if (!isParsing) { + onOpenChange(val); + setPreview(null); + } + }}> + + + Scan Event from Image + + Upload a flyer, invitation, or screenshot to automatically create an event. + + + +
+ + + {!preview ? ( +
fileInputRef.current?.click()} + > + +
+

Click to upload image

+

PNG, JPG, WEBP up to 5MB

+
+
+ ) : ( +
+
+ Upload preview + {isParsing && ( +
+
+ + Extracting Text & Analyzing... + (This may take a moment the first time it runs) +
+
+ )} +
+ {!isParsing && ( + + )} +
+ )} +
+
+
+ ); +}; + +export default ImageUploadDialog; diff --git a/src/components/QuickActions.tsx b/src/components/QuickActions.tsx index 5eca571..07569a8 100644 --- a/src/components/QuickActions.tsx +++ b/src/components/QuickActions.tsx @@ -1,44 +1,46 @@ import { Button } from "@/components/ui/button"; import { Plus, Mic, Image, MessageSquare } from "lucide-react"; -import { toast } from "sonner"; interface QuickActionsProps { onAddEvent: () => void; + onVoiceClick: () => void; + onImageClick: () => void; + onTextClick: () => void; } -const QuickActions = ({ onAddEvent }: QuickActionsProps) => { +const QuickActions = ({ onAddEvent, onVoiceClick, onImageClick, onTextClick }: QuickActionsProps) => { return (
); diff --git a/src/components/TextInputDialog.tsx b/src/components/TextInputDialog.tsx new file mode 100644 index 0000000..cd31987 --- /dev/null +++ b/src/components/TextInputDialog.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { parseEventFromAI, ParsedEvent } from "@/lib/ai"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface TextInputDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onParsed: (event: ParsedEvent) => void; +} + +const TextInputDialog = ({ open, onOpenChange, onParsed }: TextInputDialogProps) => { + const [text, setText] = useState(""); + const [isParsing, setIsParsing] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) return; + + setIsParsing(true); + try { + const parsedEvent = await parseEventFromAI(text, "text"); + if (parsedEvent) { + onParsed(parsedEvent); + onOpenChange(false); + setText(""); + } else { + toast.error("Could not understand event details. True manually."); + } + } catch (error) { + toast.error("Something went wrong."); + } finally { + setIsParsing(false); + } + }; + + return ( + + + + Smart Event Creation + + Type your event details naturally. For example: "Lunch with Sarah tomorrow at 12pm for 1 hour". + + +
+