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={() => {}} + /> + ))} +
+ +
+
+