From 8ce7f2b22926445e85a2c4146d1dd43e477ba501 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Oct 2025 07:07:25 +0000 Subject: [PATCH] feat: Initialize SustainSense prototype project This commit sets up the basic project structure, including configuration files, React components, and mock data for the SustainSense prototype. It establishes the foundation for building the sustainability dashboard and AI assistant features. Co-authored-by: surbhitoday --- index.html | 12 ++ package.json | 27 +++ postcss.config.js | 6 + src/App.jsx | 61 ++++++ src/components/AskSustainSense.jsx | 80 +++++++ src/components/ChatBubble.jsx | 43 ++++ src/components/ChatListSidebar.jsx | 30 +++ src/components/CommandDropdown.jsx | 39 ++++ src/components/ContextPill.jsx | 9 + src/components/FileCard.jsx | 25 +++ src/components/MentionDropdown.jsx | 39 ++++ src/components/SustainSensePanel.jsx | 303 +++++++++++++++++++++++++++ src/data/mockChats.js | 12 ++ src/data/mockCommands.js | 7 + src/data/mockMentions.js | 9 + src/index.css | 17 ++ src/index.jsx | 12 ++ tailwind.config.js | 24 +++ vite.config.js | 7 + 19 files changed, 762 insertions(+) create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/App.jsx create mode 100644 src/components/AskSustainSense.jsx create mode 100644 src/components/ChatBubble.jsx create mode 100644 src/components/ChatListSidebar.jsx create mode 100644 src/components/CommandDropdown.jsx create mode 100644 src/components/ContextPill.jsx create mode 100644 src/components/FileCard.jsx create mode 100644 src/components/MentionDropdown.jsx create mode 100644 src/components/SustainSensePanel.jsx create mode 100644 src/data/mockChats.js create mode 100644 src/data/mockCommands.js create mode 100644 src/data/mockMentions.js create mode 100644 src/index.css create mode 100644 src/index.jsx create mode 100644 tailwind.config.js create mode 100644 vite.config.js diff --git a/index.html b/index.html new file mode 100644 index 0000000..d55be05 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SustainSense Prototype + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..34f53a9 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "sustain-sense-prototype", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "framer-motion": "^11.3.14", + "lucide-react": "^0.453.0", + "clsx": "^2.1.1", + "papaparse": "^5.4.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "vite": "^5.4.2", + "@vitejs/plugin-react": "^4.3.1", + "tailwindcss": "^3.4.10", + "postcss": "^8.4.47", + "autoprefixer": "^10.4.20" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..f374f5a --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react' +import { MessageSquare, ArrowRight, PanelRightOpen, PanelRightClose } from 'lucide-react' +import SustainSensePanel from './components/SustainSensePanel.jsx' +import AskSustainSense from './components/AskSustainSense.jsx' + +export default function App() { + const [isPanelOpen, setIsPanelOpen] = useState(false) + const [fullPage, setFullPage] = useState(false) + + useEffect(() => { + if (isPanelOpen) { + const input = document.getElementById('ss-input') + if (input) input.focus() + } + }, [isPanelOpen]) + + return ( +
+
+
+
+
+ +
+
SustainSense
+
+
+ + +
+
+
+ +
+
+

Sustainability Dashboard

+

Prototype dashboard area. Use the header to open the assistant panel or switch to the full-page experience.

+
+
+ + {isPanelOpen && ( + setIsPanelOpen(false)} onGoFull={() => { setFullPage(true); setIsPanelOpen(false) }} /> + )} + + {fullPage && ( + setFullPage(false)} /> + )} +
+ ) +} diff --git a/src/components/AskSustainSense.jsx b/src/components/AskSustainSense.jsx new file mode 100644 index 0000000..8df55a9 --- /dev/null +++ b/src/components/AskSustainSense.jsx @@ -0,0 +1,80 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import SustainSensePanel from './SustainSensePanel' +import ChatListSidebar from './ChatListSidebar' +import ContextPill from './ContextPill' +import { mockChats } from '../data/mockChats' + +function uid(prefix = 'id') { + return `${prefix}_${Math.random().toString(36).slice(2, 9)}` +} + +export default function AskSustainSense({ onBack }) { + const [chats, setChats] = useState(() => { + const saved = localStorage.getItem('ss_chats') + return saved ? JSON.parse(saved) : mockChats + }) + const [activeId, setActiveId] = useState(chats[0]?.id || uid('chat')) + + useEffect(() => { + localStorage.setItem('ss_chats', JSON.stringify(chats)) + }, [chats]) + + const activeChat = chats.find(c => c.id === activeId) || { id: activeId, title: 'New Chat', createdAt: Date.now(), messages: [] } + + const newChat = () => { + const c = { id: uid('chat'), title: 'New Chat', createdAt: Date.now(), messages: [] } + setChats(prev => [c, ...prev]) + setActiveId(c.id) + } + + const togglePin = (id) => { + setChats(prev => prev.map(c => c.id === id ? { ...c, pinned: !c.pinned } : c)) + } + + return ( +
+
+
+ +
Ask SustainSense
+
+
+
+ c.pinned), ...chats.filter(c => !c.pinned)]} + activeId={activeId} + onNew={newChat} + onSelect={setActiveId} + onTogglePin={togglePin} + /> + +
+
+ {/* Reuse panel UI as main chat experience by placing it inline */} +
+
+
Chat: {activeChat.title}
+ {/* Inline version: we reuse panel component behavior by mounting it but not as a drawer */} +
+ {}} onGoFull={() => {}} /> +
+
+
+
+
+ + +
+
+ ) +} diff --git a/src/components/ChatBubble.jsx b/src/components/ChatBubble.jsx new file mode 100644 index 0000000..dd32cf8 --- /dev/null +++ b/src/components/ChatBubble.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import { Pin, Wand2, ExternalLink } from 'lucide-react' +import { motion } from 'framer-motion' + +export default function ChatBubble({ message, onPin, onRefine, onOpen }) { + const isUser = message.sender === 'user' + return ( + +
+ {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments} +
+ )} + {message.text && ( +
{message.text}
+ )} +
+ {new Date(message.createdAt || Date.now()).toLocaleTimeString()} +
+ {!isUser && ( + <> + + + + + )} +
+
+ + ) +} diff --git a/src/components/ChatListSidebar.jsx b/src/components/ChatListSidebar.jsx new file mode 100644 index 0000000..b6fb85c --- /dev/null +++ b/src/components/ChatListSidebar.jsx @@ -0,0 +1,30 @@ +import React from 'react' + +export default function ChatListSidebar({ chats, activeId, onNew, onSelect, onTogglePin }) { + return ( + + ) +} diff --git a/src/components/CommandDropdown.jsx b/src/components/CommandDropdown.jsx new file mode 100644 index 0000000..3150ffb --- /dev/null +++ b/src/components/CommandDropdown.jsx @@ -0,0 +1,39 @@ +import React, { useEffect, useRef } from 'react' + +export default function CommandDropdown({ items, activeIndex, onSelect, onClose, anchorRef }) { + const listRef = useRef(null) + + useEffect(() => { + const handleKey = (e) => { + if (e.key === 'Escape') onClose?.() + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [onClose]) + + const rect = anchorRef?.current?.getBoundingClientRect?.() + + return ( +
+
    + {items.map((item, idx) => ( +
  • { e.preventDefault(); onSelect(item) }} + > +
    {item.title}
    +
    {item.description}
    +
  • + ))} +
+
+ ) +} diff --git a/src/components/ContextPill.jsx b/src/components/ContextPill.jsx new file mode 100644 index 0000000..a4f6d41 --- /dev/null +++ b/src/components/ContextPill.jsx @@ -0,0 +1,9 @@ +import React from 'react' + +export default function ContextPill({ label }) { + return ( +
+ {label} +
+ ) +} diff --git a/src/components/FileCard.jsx b/src/components/FileCard.jsx new file mode 100644 index 0000000..bf2a223 --- /dev/null +++ b/src/components/FileCard.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { FileSpreadsheet, FileText, Image as ImageIcon } from 'lucide-react' + +export default function FileCard({ file, preview }) { + const ext = (file?.name || '').split('.').pop()?.toLowerCase() + const isCSV = ext === 'csv' || ext === 'xlsx' + const isPDF = ext === 'pdf' + const isImage = ['png','jpg','jpeg','gif','webp'].includes(ext) + + return ( +
+
+ {isCSV && } + {isPDF && } + {isImage && } +
{file?.name}
+
+ {preview && ( +
+ {preview} +
+ )} +
+ ) +} diff --git a/src/components/MentionDropdown.jsx b/src/components/MentionDropdown.jsx new file mode 100644 index 0000000..1e375c3 --- /dev/null +++ b/src/components/MentionDropdown.jsx @@ -0,0 +1,39 @@ +import React, { useEffect, useRef } from 'react' + +export default function MentionDropdown({ items, activeIndex, onSelect, onClose, anchorRef }) { + const listRef = useRef(null) + + useEffect(() => { + const handleKey = (e) => { + if (e.key === 'Escape') onClose?.() + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [onClose]) + + const rect = anchorRef?.current?.getBoundingClientRect?.() + + return ( +
+
    + {items.map((item, idx) => ( +
  • { e.preventDefault(); onSelect(item) }} + > +
    {item.label}
    +
    {item.type}
    +
  • + ))} +
+
+ ) +} diff --git a/src/components/SustainSensePanel.jsx b/src/components/SustainSensePanel.jsx new file mode 100644 index 0000000..3834bda --- /dev/null +++ b/src/components/SustainSensePanel.jsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { mockCommands } from '../data/mockCommands' +import { mockMentions } from '../data/mockMentions' +import CommandDropdown from './CommandDropdown' +import MentionDropdown from './MentionDropdown' +import ChatBubble from './ChatBubble' +import FileCard from './FileCard' +import Papa from 'papaparse' +import * as XLSX from 'xlsx' + +function uid(prefix = 'id') { + return `${prefix}_${Math.random().toString(36).slice(2, 9)}` +} + +function loadWidth() { + const w = localStorage.getItem('ss_panel_width') + const parsed = w ? parseInt(w, 10) : 420 + return Math.min(900, Math.max(360, parsed || 420)) +} + +export default function SustainSensePanel({ onClose, onGoFull }) { + const [width, setWidth] = useState(loadWidth()) + const [dragging, setDragging] = useState(false) + const [messages, setMessages] = useState(() => { + const saved = localStorage.getItem('ss_panel_chat') + return saved ? JSON.parse(saved) : [] + }) + + const inputRef = useRef(null) + const [text, setText] = useState('') + const [attachments, setAttachments] = useState([]) + const [showCmd, setShowCmd] = useState(false) + const [showMention, setShowMention] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + const [anchorRectEl, setAnchorRectEl] = useState(null) + + useEffect(() => { + localStorage.setItem('ss_panel_chat', JSON.stringify(messages)) + }, [messages]) + + const onMouseDownResize = useCallback((e) => { + e.preventDefault() + const startX = e.clientX + const startWidth = width + + function onMove(ev) { + const dx = startX - ev.clientX + const newW = Math.min(900, Math.max(360, startWidth + dx)) + setWidth(newW) + localStorage.setItem('ss_panel_width', String(newW)) + } + + function onUp() { + setDragging(false) + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + + setDragging(true) + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [width]) + + const filteredCommands = useMemo(() => { + const token = text.split(/\s+/).pop() || '' + const q = token.startsWith('/') ? token.toLowerCase() : '' + if (!q) return [] + return mockCommands.filter(c => c.title.toLowerCase().includes(q)) + }, [text]) + + const filteredMentions = useMemo(() => { + const token = text.split(/\s+/).pop() || '' + const q = token.startsWith('@') ? token.slice(1).toLowerCase() : '' + if (!q) return mockMentions + return mockMentions.filter(m => m.label.toLowerCase().includes(q) || m.type.toLowerCase().includes(q)) + }, [text]) + + useEffect(() => { + const token = text.split(/\s+/).pop() || '' + setShowCmd(token.startsWith('/')) + setShowMention(token.startsWith('@')) + }, [text]) + + const handleKeyDown = (e) => { + if ((e.key === 'Enter' && !e.shiftKey) || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + e.preventDefault() + if (text.trim().length === 0 && attachments.length === 0) return + send() + return + } + if (e.key === 'Escape') { + setShowCmd(false); setShowMention(false) + return + } + if (showCmd || showMention) { + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, (showCmd ? filteredCommands : filteredMentions).length - 1)) } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)) } + if (e.key === 'Enter') { + e.preventDefault() + const list = showCmd ? filteredCommands : filteredMentions + const item = list[activeIndex] + if (item) onSelectAutocomplete(item) + } + } + } + + const onSelectAutocomplete = (item) => { + const parts = text.split(/\s+/) + parts.pop() + let insert = '' + if ('trigger' in item) insert = item.title + else insert = `@${item.label}` + const newText = [...parts, insert].filter(Boolean).join(' ') + ' ' + setText(newText) + setShowCmd(false); setShowMention(false) + } + + const handleFiles = async (files) => { + const previews = [] + for (const file of files) { + const ext = file.name.split('.').pop().toLowerCase() + if (ext === 'csv') { + const text = await file.text() + const parsed = Papa.parse(text, { header: true }) + const rows = parsed.data.slice(0, 5) + previews.push( +
+ + + {Object.keys(rows[0] || {}).map(k => )} + + + {rows.map((r, i) => ( + + {Object.values(r).map((v, j) => )} + + ))} + +
{k}
{String(v)}
+
+ ) + } else if (ext === 'xlsx') { + const buf = await file.arrayBuffer() + const wb = XLSX.read(buf, { type: 'array' }) + const ws = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }).slice(0, 6) + previews.push( +
+ + + {rows.map((r, i) => ( + + {r.map((v, j) => )} + + ))} + +
{String(v)}
+
+ ) + } else { + previews.push(
{file.name}
) + } + } + setAttachments(prev => [...prev, ...files].slice(0)) + setMessages(prev => [...prev, { id: uid('msg'), sender: 'user', createdAt: Date.now(), attachments: previews }]) + } + + const send = () => { + const userMsg = { id: uid('msg'), sender: 'user', text, createdAt: Date.now() } + setMessages(prev => [...prev, userMsg]) + setText('') + + const typingId = uid('typing') + setMessages(prev => [...prev, { id: typingId, sender: 'assistant', text: 'Analyzing data…', typing: true, createdAt: Date.now() }]) + + const latency = 800 + Math.floor(Math.random() * 800) + setTimeout(() => { + setMessages(prev => prev.filter(m => m.id !== typingId)) + const response = makeAssistantResponse(userMsg.text) + setMessages(prev => [...prev, { id: uid('msg'), sender: 'assistant', ...response, createdAt: Date.now() }]) + }, latency) + } + + function makeAssistantResponse(t) { + const low = t.toLowerCase() + if (low.startsWith('/compare')) { + const svg = ( + + + + + + + + Mock emissions comparison + + ) + return { text: 'Comparison preview generated for selected facilities/periods.', attachments: [svg] } + } + return { text: 'Here are some preliminary insights based on your data and context. You can refine or open this in analysis for deeper exploration.' } + } + + const onFileInput = (e) => { + const files = Array.from(e.target.files || []) + handleFiles(files) + e.currentTarget.value = '' + } + + const onDrop = (e) => { + e.preventDefault() + const files = Array.from(e.dataTransfer.files || []) + handleFiles(files) + } + + return ( + +
+
+
SustainSense
+
+ + +
+
+
+
+ +
e.preventDefault()}> + {messages.map(m => ( + {}} + onRefine={() => setText(t => `${t}${t.endsWith(' ') || t.length===0 ? '' : ' '}Refine: `)} + onOpen={() => {}} + /> + ))} +
+ +
+
+