Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SustainSense Prototype</title>
</head>
<body class="bg-neutral-100">
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
61 changes: 61 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex flex-col bg-neutral-100 text-gray-800 font-sans">
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur border-b border-neutral-200">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-md">
<MessageSquare size={18} />
</div>
<div className="text-lg font-semibold">SustainSense</div>
</div>
<div className="flex items-center gap-2">
<button
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-indigo-600 text-white shadow hover:bg-indigo-500 transition"
onClick={() => setFullPage(true)}
>
Ask SustainSense <ArrowRight size={16} />
</button>
<button
aria-label="Toggle SustainSense panel"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white border border-neutral-300 hover:bg-neutral-50 transition"
onClick={() => setIsPanelOpen(v => !v)}
>
{isPanelOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
</button>
</div>
</div>
</header>

<main className="flex-1 max-w-7xl w-full mx-auto p-4">
<div className="rounded-2xl border border-neutral-200 bg-white shadow-sm p-10 text-neutral-700">
<h1 className="text-2xl font-semibold mb-2">Sustainability Dashboard</h1>
<p className="text-neutral-600">Prototype dashboard area. Use the header to open the assistant panel or switch to the full-page experience.</p>
</div>
</main>

{isPanelOpen && (
<SustainSensePanel onClose={() => setIsPanelOpen(false)} onGoFull={() => { setFullPage(true); setIsPanelOpen(false) }} />
)}

{fullPage && (
<AskSustainSense onBack={() => setFullPage(false)} />
)}
</div>
)
}
80 changes: 80 additions & 0 deletions src/components/AskSustainSense.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 bg-white z-50 flex flex-col">
<div className="border-b border-neutral-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="px-2 py-1 rounded border bg-neutral-100" onClick={onBack}>Back to Dashboard</button>
<div className="font-semibold">Ask SustainSense</div>
</div>
</div>
<div className="flex flex-1 min-h-0">
<ChatListSidebar
chats={[...chats.filter(c => c.pinned), ...chats.filter(c => !c.pinned)]}
activeId={activeId}
onNew={newChat}
onSelect={setActiveId}
onTogglePin={togglePin}
/>

<main className="flex-1 flex flex-col">
<div className="flex-1 p-4">
{/* Reuse panel UI as main chat experience by placing it inline */}
<div className="max-w-3xl mx-auto">
<div className="rounded-2xl border border-neutral-200 bg-white shadow-sm p-6">
<div className="text-sm text-neutral-600 mb-3">Chat: {activeChat.title}</div>
{/* Inline version: we reuse panel component behavior by mounting it but not as a drawer */}
<div className="relative">
<SustainSensePanel onClose={() => {}} onGoFull={() => {}} />
</div>
</div>
</div>
</div>
</main>

<aside className="w-72 border-l border-neutral-200 bg-white p-3">
<div className="font-semibold mb-2">Context</div>
<div className="flex flex-wrap">
<ContextPill label="Facility 1A" />
<ContextPill label="Year 2024" />
<ContextPill label="Scope 2" />
</div>

<div className="font-semibold mt-4 mb-2">Uploaded Files</div>
<div className="text-sm text-neutral-600">Files dropped or uploaded in this session will appear here.</div>
</aside>
</div>
</div>
)
}
43 changes: 43 additions & 0 deletions src/components/ChatBubble.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${isUser ? 'justify-end' : 'justify-start'} w-full`}
aria-live="polite"
>
<div className={`max-w-[80%] rounded-2xl px-4 py-3 shadow-md border ${isUser ? 'bg-indigo-50 border-indigo-100' : 'bg-white border-neutral-200'}`}>
{message.attachments && message.attachments.length > 0 && (
<div className="mb-2 space-y-2">
{message.attachments}
</div>
)}
{message.text && (
<div className="whitespace-pre-wrap leading-relaxed">{message.text}</div>
)}
<div className="mt-2 text-xs text-neutral-500 flex items-center gap-2">
<span>{new Date(message.createdAt || Date.now()).toLocaleTimeString()}</span>
<div className="flex-1" />
{!isUser && (
<>
<button className="inline-flex items-center gap-1 hover:text-neutral-700" onClick={onPin}>
<Pin size={14} /> Pin
</button>
<button className="inline-flex items-center gap-1 hover:text-neutral-700" onClick={onRefine}>
<Wand2 size={14} /> Refine
</button>
<button className="inline-flex items-center gap-1 hover:text-neutral-700" onClick={onOpen}>
<ExternalLink size={14} /> Open in Analysis
</button>
</>
)}
</div>
</div>
</motion.div>
)
}
30 changes: 30 additions & 0 deletions src/components/ChatListSidebar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'

export default function ChatListSidebar({ chats, activeId, onNew, onSelect, onTogglePin }) {
return (
<aside className="w-64 border-r border-neutral-200 bg-white">
<div className="p-3 border-b border-neutral-200 flex items-center justify-between">
<div className="font-semibold">Chats</div>
<button className="px-2 py-1 text-sm rounded bg-indigo-600 text-white" onClick={onNew}>New Chat</button>
</div>
<div className="p-2 space-y-1 overflow-auto max-h-[calc(100vh-56px)]">
{chats.map(c => (
<div
key={c.id}
onClick={() => onSelect(c.id)}
className={`p-2 rounded cursor-pointer border ${c.id === activeId ? 'bg-indigo-50 border-indigo-100' : 'bg-white border-neutral-200 hover:bg-neutral-50'}`}
>
<div className="text-sm font-medium flex items-center justify-between">
<span className="truncate">{c.title || 'Untitled'}</span>
<button
className={`ml-2 text-xs px-1.5 py-0.5 rounded ${c.pinned ? 'bg-purple-100 text-purple-700' : 'bg-neutral-100 text-neutral-700'}`}
onClick={(e) => { e.stopPropagation(); onTogglePin(c.id) }}
>{c.pinned ? 'Pinned' : 'Pin'}</button>
</div>
<div className="text-xs text-neutral-500">{new Date(c.createdAt).toLocaleDateString()}</div>
</div>
))}
</div>
</aside>
)
}
39 changes: 39 additions & 0 deletions src/components/CommandDropdown.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="listbox"
aria-label="Commands"
className="fixed z-50 rounded-xl border border-neutral-200 bg-white shadow-lg w-80 overflow-hidden"
style={{ top: (rect?.bottom || 0) + 8, left: rect?.left || 0 }}
>
<ul ref={listRef} className="max-h-64 overflow-auto">
{items.map((item, idx) => (
<li
key={item.id}
role="option"
aria-selected={idx === activeIndex}
className={`px-3 py-2 cursor-pointer text-sm ${idx === activeIndex ? 'bg-indigo-50' : 'hover:bg-neutral-50'}`}
onMouseDown={(e) => { e.preventDefault(); onSelect(item) }}
>
<div className="font-medium">{item.title}</div>
<div className="text-xs text-neutral-500">{item.description}</div>
</li>
))}
</ul>
</div>
)
}
9 changes: 9 additions & 0 deletions src/components/ContextPill.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react'

export default function ContextPill({ label }) {
return (
<div className="inline-flex items-center px-3 py-1 rounded-full bg-neutral-100 border border-neutral-200 text-xs text-neutral-700 mr-2 mb-2">
{label}
</div>
)
}
25 changes: 25 additions & 0 deletions src/components/FileCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
<div className="flex items-center gap-3">
{isCSV && <FileSpreadsheet className="text-indigo-600" size={18} />}
{isPDF && <FileText className="text-indigo-600" size={18} />}
{isImage && <ImageIcon className="text-indigo-600" size={18} />}
<div className="text-sm font-medium truncate">{file?.name}</div>
</div>
{preview && (
<div className="mt-2 overflow-auto max-h-48">
{preview}
</div>
)}
</div>
)
}
39 changes: 39 additions & 0 deletions src/components/MentionDropdown.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="listbox"
aria-label="Mentions"
className="fixed z-50 rounded-xl border border-neutral-200 bg-white shadow-lg w-72 overflow-hidden"
style={{ top: (rect?.bottom || 0) + 8, left: rect?.left || 0 }}
>
<ul ref={listRef} className="max-h-64 overflow-auto">
{items.map((item, idx) => (
<li
key={item.id}
role="option"
aria-selected={idx === activeIndex}
className={`px-3 py-2 cursor-pointer text-sm ${idx === activeIndex ? 'bg-indigo-50' : 'hover:bg-neutral-50'}`}
onMouseDown={(e) => { e.preventDefault(); onSelect(item) }}
>
<div className="font-medium">{item.label}</div>
<div className="text-xs text-neutral-500">{item.type}</div>
</li>
))}
</ul>
</div>
)
}
Loading