services now hooked up to UI
This commit is contained in:
parent
175517b7b3
commit
871351ddec
29 changed files with 2096 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.env.local
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
CLAUDE.md
|
||||
24
claudash/.env.example
Normal file
24
claudash/.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# REQUIRED: Anthropic API Key
|
||||
REACT_APP_ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
|
||||
# Data sources (comma-separated URLs)
|
||||
# Supports: .json (auto-parsed), .rss/.xml (RSS feeds), plain URLs (scraped)
|
||||
REACT_APP_DATA_SOURCES=https://www.reddit.com/r/technology.json,https://hacker-news.firebaseio.com/v0/topstories.json,https://www.reddit.com/r/worldnews.json
|
||||
|
||||
# Location for weather (supports "city, state, country" or zip code)
|
||||
REACT_APP_LOCATION=harrisburg, pa, usa
|
||||
|
||||
# Optional: Custom CORS proxy (default: allorigins.win)
|
||||
REACT_APP_CORS_PROXY=https://api.allorigins.win/raw?url=
|
||||
|
||||
# Optional: Refresh interval in minutes (default: 15)
|
||||
REACT_APP_REFRESH_INTERVAL=15
|
||||
|
||||
# Optional: Days of history to keep (default: 7)
|
||||
REACT_APP_HISTORY_DAYS=7
|
||||
|
||||
# Optional: Claude model (default: claude-sonnet-4-5-20250929)
|
||||
REACT_APP_CLAUDE_MODEL=claude-3-5-sonnet-20241022
|
||||
|
||||
# Optional: Debug mode (shows raw data, enables dummy data)
|
||||
REACT_APP_DEBUG_MODE=false
|
||||
24
claudash/.gitignore
vendored
Normal file
24
claudash/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
claudash/README.md
Normal file
16
claudash/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
claudash/eslint.config.js
Normal file
29
claudash/eslint.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
claudash/index.html
Normal file
13
claudash/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>claudash</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
claudash/package.json
Normal file
36
claudash/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "claudash",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"proxy": "node proxy-server.js",
|
||||
"start": "npm run proxy & npm run dev",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@mui/x-charts": "^8.27.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"vite": "^4.5.3"
|
||||
}
|
||||
}
|
||||
59
claudash/proxy-server.js
Normal file
59
claudash/proxy-server.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Proxy endpoint for Claude API
|
||||
app.post('/api/claude', async (req, res) => {
|
||||
try {
|
||||
const { messages, apiKey, model, maxTokens } = req.body;
|
||||
|
||||
console.log('=== Proxy received request ===');
|
||||
console.log('Model:', model);
|
||||
console.log('Max tokens:', maxTokens);
|
||||
console.log('Messages count:', messages?.length);
|
||||
console.log('API Key present:', !!apiKey);
|
||||
|
||||
const requestBody = {
|
||||
model: model,
|
||||
max_tokens: maxTokens,
|
||||
messages: messages
|
||||
};
|
||||
|
||||
console.log('Request body:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('Claude API response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Claude API error:', error);
|
||||
return res.status(response.status).json({ error });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Claude API success, response content length:', data.content?.[0]?.text?.length);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Proxy server running on http://localhost:${PORT}`);
|
||||
console.log('This proxy allows the frontend to communicate with the Anthropic API');
|
||||
});
|
||||
1
claudash/public/vite.svg
Normal file
1
claudash/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
claudash/src/App.css
Normal file
42
claudash/src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
23
claudash/src/App.jsx
Normal file
23
claudash/src/App.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import DebugPanel from './components/DebugPanel';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
|
||||
function App() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const debugMode = import.meta.env.REACT_APP_DEBUG_MODE === 'true';
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
|
||||
<CssBaseline />
|
||||
<Dashboard
|
||||
themeToggle={() => setIsDark(!isDark)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{debugMode && <DebugPanel />}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
claudash/src/assets/react.svg
Normal file
1
claudash/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
144
claudash/src/components/ChatInterface.jsx
Normal file
144
claudash/src/components/ChatInterface.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Box, TextField, IconButton, Paper, Typography } from '@mui/material';
|
||||
import { Send, ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||
import { sendChatMessage } from '../services/claudeService';
|
||||
|
||||
function ChatInterface() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage = { role: 'user', content: input };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setIsExpanded(true); // Expand chat when sending a message
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage(input, messages);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: response }]);
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error. Please try again.'
|
||||
}]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: isExpanded ? '40vh' : '64px',
|
||||
transition: 'height 0.3s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
{/* Expand/Collapse Button */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<IconButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
size="small"
|
||||
>
|
||||
{isExpanded ? <ExpandMore /> : <ExpandLess />}
|
||||
</IconButton>
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
Chat with Claude
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages area - only visible when expanded */}
|
||||
{isExpanded && (
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{messages.length === 0 && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Ask me anything about your briefing or the news...
|
||||
</Typography>
|
||||
)}
|
||||
{messages.map((msg, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
mb: 2,
|
||||
textAlign: msg.role === 'user' ? 'right' : 'left'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
bgcolor: msg.role === 'user' ? 'primary.main' : 'grey.800',
|
||||
color: 'white',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
maxWidth: '70%'
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input area - always visible */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: isExpanded ? 1 : 0,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
backgroundColor: 'background.paper'
|
||||
}}>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Claude anything..."
|
||||
disabled={loading}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
color="primary"
|
||||
>
|
||||
<Send />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatInterface;
|
||||
173
claudash/src/components/Dashboard.jsx
Normal file
173
claudash/src/components/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Box, Container, CircularProgress, IconButton, Alert } from '@mui/material';
|
||||
import { Brightness4, Brightness7, Refresh } from '@mui/icons-material';
|
||||
import { fetchAllSources } from '../services/dataFetcher';
|
||||
import { generateBriefing } from '../services/claudeService';
|
||||
import { saveBriefing, getPreviousBriefing, hasTodaysBriefing, getTodaysBriefing } from '../services/storageService';
|
||||
import BriefingWidget from './widgets/BriefingWidget';
|
||||
import WeatherWidget from './widgets/WeatherWidget';
|
||||
import NewsWidget from './widgets/NewsWidget';
|
||||
import ChatInterface from './ChatInterface';
|
||||
|
||||
function Dashboard({ themeToggle, isDark }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [briefing, setBriefing] = useState('');
|
||||
const [sourceData, setSourceData] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
async function loadDashboard(forceRefresh = false) {
|
||||
try {
|
||||
console.log('=== loadDashboard called ===');
|
||||
console.log('Force refresh:', forceRefresh);
|
||||
setError(null);
|
||||
|
||||
// Check if we already have today's briefing (unless forcing refresh)
|
||||
if (!forceRefresh && hasTodaysBriefing()) {
|
||||
console.log('Using cached briefing from today');
|
||||
const todaysBriefing = getTodaysBriefing();
|
||||
setBriefing(todaysBriefing.briefing);
|
||||
setSourceData(todaysBriefing.sources || {});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Fetching fresh data...');
|
||||
|
||||
// Fetch all configured sources
|
||||
const data = await fetchAllSources();
|
||||
console.log('Fetched sources:', Object.keys(data.sources || {}));
|
||||
setSourceData(data.sources || {});
|
||||
|
||||
// Check if API key is configured
|
||||
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
|
||||
console.log('API key configured:', !!apiKey);
|
||||
|
||||
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
|
||||
console.warn('API key not configured properly');
|
||||
setBriefing('Please add your Anthropic API key to .env.local to generate briefings.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get yesterday's briefing for comparison
|
||||
const previousBriefing = getPreviousBriefing(1);
|
||||
console.log('Previous briefing exists:', !!previousBriefing);
|
||||
|
||||
console.log('Calling generateBriefing...');
|
||||
// Generate today's briefing with Claude
|
||||
const newBriefing = await generateBriefing(data, previousBriefing);
|
||||
console.log('Briefing generated, length:', newBriefing?.length);
|
||||
setBriefing(newBriefing);
|
||||
|
||||
// Save to localStorage
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
console.log('Saving briefing for:', today);
|
||||
saveBriefing(today, newBriefing, data.sources);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard load error:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
setError(`Failed to load dashboard: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadDashboard(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', pb: 10 }}>
|
||||
{/* Top toolbar */}
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
zIndex: 100
|
||||
}}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
}}
|
||||
title="Clear all cache and reload"
|
||||
color="error"
|
||||
>
|
||||
🗑️
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh data"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={themeToggle}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? <Brightness7 /> : <Brightness4 />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Container maxWidth="xl" sx={{ pt: 4 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Widget Grid - easily rearrangeable */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: 4
|
||||
}}>
|
||||
<BriefingWidget briefing={briefing} sx={{ flex: '1 1 100%' }} />
|
||||
|
||||
<WeatherWidget
|
||||
data={sourceData.weather}
|
||||
sx={{ flex: '1 1 300px' }}
|
||||
/>
|
||||
|
||||
{/* Render NewsWidget for each news source */}
|
||||
{Object.entries(sourceData).map(([key, data]) => {
|
||||
if (key === 'weather') return null;
|
||||
return (
|
||||
<NewsWidget
|
||||
key={key}
|
||||
data={data}
|
||||
sx={{ flex: '1 1 400px' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Fixed chat at bottom */}
|
||||
<ChatInterface />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
81
claudash/src/components/DebugPanel.jsx
Normal file
81
claudash/src/components/DebugPanel.jsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useState } from 'react';
|
||||
import { Button, Box, Paper, Typography } from '@mui/material';
|
||||
import { Download, Upload } from '@mui/icons-material';
|
||||
import { exportAllData, importDummyData } from '../services/storageService';
|
||||
|
||||
function DebugPanel() {
|
||||
const [dummyMode, setDummyMode] = useState(false);
|
||||
|
||||
const handleExport = () => {
|
||||
const data = exportAllData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claudash-debug-${new Date().toISOString()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
importDummyData(data);
|
||||
setDummyMode(true);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error importing data:', error);
|
||||
alert('Failed to import data. Please check the file format.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ position: 'fixed', bottom: 80, right: 16, p: 2, width: 250, zIndex: 999 }}>
|
||||
<Typography variant="h6" gutterBottom>Debug Tools</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<Download />}
|
||||
onClick={handleExport}
|
||||
sx={{ mb: 1 }}
|
||||
variant="outlined"
|
||||
>
|
||||
Export Data
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
component="label"
|
||||
startIcon={<Upload />}
|
||||
variant="outlined"
|
||||
>
|
||||
Import Dummy Data
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{dummyMode && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="warning.main"
|
||||
sx={{ mt: 1, display: 'block' }}
|
||||
>
|
||||
Using dummy data
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DebugPanel;
|
||||
36
claudash/src/components/widgets/BriefingWidget.jsx
Normal file
36
claudash/src/components/widgets/BriefingWidget.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Typography, Skeleton, Box } from '@mui/material';
|
||||
import GenericWidget from './GenericWidget';
|
||||
|
||||
function BriefingWidget({ briefing, sx }) {
|
||||
return (
|
||||
<GenericWidget
|
||||
title="Your Daily Briefing"
|
||||
sx={{ ...sx }}
|
||||
>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{briefing ? (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
lineHeight: 1.7,
|
||||
fontSize: '1.05rem'
|
||||
}}
|
||||
>
|
||||
{briefing}
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem', mb: 1 }} />
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
export default BriefingWidget;
|
||||
22
claudash/src/components/widgets/GenericWidget.jsx
Normal file
22
claudash/src/components/widgets/GenericWidget.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Paper, Typography, Box } from '@mui/material';
|
||||
|
||||
function GenericWidget({ title, data, children, sx, ...props }) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
minWidth: 300,
|
||||
...sx
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>{title}</Typography>
|
||||
<Box>
|
||||
{children || <Typography>{JSON.stringify(data)}</Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GenericWidget;
|
||||
157
claudash/src/components/widgets/NewsWidget.jsx
Normal file
157
claudash/src/components/widgets/NewsWidget.jsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Typography, Box, Link, Chip, List, ListItem, ListItemText } from '@mui/material';
|
||||
import { OpenInNew, Comment, TrendingUp } from '@mui/icons-material';
|
||||
import GenericWidget from './GenericWidget';
|
||||
|
||||
function NewsWidget({ data, sx }) {
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<GenericWidget title="News" sx={{ opacity: 0.5, ...sx }}>
|
||||
<Typography color="text.secondary">
|
||||
{data?.message || 'News data unavailable'}
|
||||
</Typography>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Reddit data
|
||||
if (data.type === 'reddit' && data.posts) {
|
||||
const subreddit = data.url?.match(/\/r\/([^\/\.]+)/)?.[1] || 'Reddit';
|
||||
|
||||
return (
|
||||
<GenericWidget title={`r/${subreddit}`} sx={{ ...sx }}>
|
||||
<List dense>
|
||||
{data.posts.slice(0, 5).map((post, index) => (
|
||||
<ListItem key={index} disablePadding sx={{ mb: 1 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'start', gap: 1 }}>
|
||||
<Link
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{post.title}
|
||||
<OpenInNew sx={{ fontSize: 14 }} />
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip
|
||||
icon={<TrendingUp />}
|
||||
label={post.score}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<Comment />}
|
||||
label={post.comments}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, alignSelf: 'center' }}>
|
||||
by {post.author}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle RSS feed data
|
||||
if (data.type === 'rss' && data.items) {
|
||||
return (
|
||||
<GenericWidget title={data.feedTitle || 'RSS Feed'} sx={{ ...sx }}>
|
||||
<List dense>
|
||||
{data.items.slice(0, 5).map((item, index) => (
|
||||
<ListItem key={index} disablePadding sx={{ mb: 1 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Link
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{item.title}
|
||||
<OpenInNew sx={{ fontSize: 14 }} />
|
||||
</Typography>
|
||||
</Link>
|
||||
}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.pubDate && new Date(item.pubDate).toLocaleDateString()}
|
||||
</Typography>
|
||||
{item.description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description.replace(/<[^>]*>?/gm, '')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Hacker News data
|
||||
if (data.type === 'hackernews' && data.storyIds) {
|
||||
return (
|
||||
<GenericWidget title="Hacker News" sx={{ ...sx }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{data.storyIds.length} top stories loaded
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
(Story details will load when API is configured)
|
||||
</Typography>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
// Default fallback for unknown data types
|
||||
return (
|
||||
<GenericWidget title="News" sx={{ ...sx }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Data format not recognized
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{JSON.stringify(data).substring(0, 100)}...
|
||||
</Typography>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewsWidget;
|
||||
92
claudash/src/components/widgets/WeatherWidget.jsx
Normal file
92
claudash/src/components/widgets/WeatherWidget.jsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Typography, Box, Chip, Stack } from '@mui/material';
|
||||
import { Cloud, WbSunny, Thunderstorm, AcUnit } from '@mui/icons-material';
|
||||
import GenericWidget from './GenericWidget';
|
||||
|
||||
function WeatherWidget({ data, sx }) {
|
||||
const getWeatherIcon = (description) => {
|
||||
const desc = description?.toLowerCase() || '';
|
||||
if (desc.includes('sun') || desc.includes('clear')) return <WbSunny />;
|
||||
if (desc.includes('cloud') || desc.includes('overcast')) return <Cloud />;
|
||||
if (desc.includes('rain') || desc.includes('storm')) return <Thunderstorm />;
|
||||
if (desc.includes('snow') || desc.includes('ice')) return <AcUnit />;
|
||||
return <Cloud />;
|
||||
};
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<GenericWidget title="Weather" sx={{ opacity: 0.5, ...sx }}>
|
||||
<Typography color="text.secondary">
|
||||
{data?.message || 'Weather data unavailable'}
|
||||
</Typography>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
const current = data.current || {};
|
||||
const location = data.nearestArea?.areaName?.[0]?.value || data.location || 'Unknown';
|
||||
const temp = current.temp_F || current.temp_C || '?';
|
||||
const tempUnit = current.temp_F ? '°F' : '°C';
|
||||
const description = current.weatherDesc?.[0]?.value || 'No description';
|
||||
const humidity = current.humidity || '?';
|
||||
const windSpeed = current.windspeedMiles || current.windspeedKmph || '?';
|
||||
const windUnit = current.windspeedMiles ? 'mph' : 'km/h';
|
||||
|
||||
return (
|
||||
<GenericWidget title={`Weather - ${location}`} sx={{ ...sx }}>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
{getWeatherIcon(description)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h3" component="span">
|
||||
{temp}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="span" color="text.secondary">
|
||||
{tempUnit}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip
|
||||
label={`Humidity: ${humidity}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`Wind: ${windSpeed} ${windUnit}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{data.forecast && data.forecast.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Forecast
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{data.forecast.slice(0, 3).map((day, index) => (
|
||||
<Box key={index} sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="caption" display="block">
|
||||
Day {index + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{day.maxtempF || day.maxtempC}° / {day.mintempF || day.mintempC}°
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeatherWidget;
|
||||
68
claudash/src/index.css
Normal file
68
claudash/src/index.css
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
9
claudash/src/main.jsx
Normal file
9
claudash/src/main.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
237
claudash/src/services/claudeService.js
Normal file
237
claudash/src/services/claudeService.js
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import {
|
||||
ANTHROPIC_API_URL,
|
||||
DEFAULT_CLAUDE_MODEL,
|
||||
MAX_TOKENS
|
||||
} from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Make a request to Claude API
|
||||
* @param {string} prompt - The prompt to send
|
||||
* @param {Array} messages - Optional message history
|
||||
* @returns {Promise<string>} Claude's response
|
||||
*/
|
||||
async function callClaude(prompt, messages = []) {
|
||||
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
|
||||
|
||||
console.log('=== callClaude function ===');
|
||||
console.log('API Key configured:', !!apiKey);
|
||||
console.log('API Key starts with:', apiKey?.substring(0, 15) + '...');
|
||||
|
||||
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
|
||||
console.error('Anthropic API key not configured');
|
||||
return 'Please configure your Anthropic API key in .env.local';
|
||||
}
|
||||
|
||||
const model = import.meta.env.REACT_APP_CLAUDE_MODEL || DEFAULT_CLAUDE_MODEL;
|
||||
console.log('Using model:', model);
|
||||
console.log('Max tokens:', MAX_TOKENS);
|
||||
console.log('Prompt length:', prompt.length);
|
||||
console.log('Previous messages:', messages.length);
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
maxTokens: MAX_TOKENS,
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'user', content: prompt }
|
||||
]
|
||||
};
|
||||
|
||||
console.log('Sending request to proxy...');
|
||||
console.log('Request messages:', requestBody.messages.map(m => ({ role: m.role, contentLength: m.content.length })));
|
||||
|
||||
// Use local proxy server instead of direct API call
|
||||
const response = await fetch('http://localhost:3001/api/claude', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('Proxy response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Proxy error response:', error);
|
||||
throw new Error(`Claude API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data structure:', Object.keys(data));
|
||||
console.log('Response content:', data.content?.[0]?.text?.substring(0, 200) + '...');
|
||||
return data.content[0].text;
|
||||
} catch (error) {
|
||||
console.error('Error calling Claude:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build briefing prompt with memory
|
||||
* @param {Object} currentData - Current source data
|
||||
* @param {Object} previousBriefing - Previous briefing object
|
||||
* @returns {string} The prompt
|
||||
*/
|
||||
function buildBriefingPrompt(currentData, previousBriefing) {
|
||||
let prompt = `You are creating a personalized daily briefing. Be conversational and highlight what's interesting.
|
||||
|
||||
CURRENT DATA:
|
||||
`;
|
||||
|
||||
// Add all current source data
|
||||
Object.entries(currentData.sources || currentData).forEach(([source, data]) => {
|
||||
if (data && !data.error) {
|
||||
prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`;
|
||||
}
|
||||
});
|
||||
|
||||
// Add historical context if available
|
||||
if (previousBriefing && previousBriefing.briefing) {
|
||||
prompt += `\n\nYESTERDAY'S BRIEFING:\n${previousBriefing.briefing}\n`;
|
||||
prompt += `\nHighlight what's changed, any follow-up stories, or interesting trends.`;
|
||||
}
|
||||
|
||||
prompt += `\n\nCreate a friendly briefing (300-400 words) that:
|
||||
- Starts with a warm greeting appropriate for the time of day
|
||||
- Highlights the most interesting items from the data
|
||||
- Notes anything that's changed since yesterday (if applicable)
|
||||
- Has personality and isn't just a dry list
|
||||
- Includes relevant weather information if available
|
||||
- Ends with something engaging or thought-provoking
|
||||
|
||||
Focus on the most newsworthy and interesting items. Don't try to mention everything.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate daily briefing
|
||||
* @param {Object} currentData - Current data from all sources
|
||||
* @param {Object} previousBriefing - Previous briefing for comparison
|
||||
* @returns {Promise<string>} Generated briefing text
|
||||
*/
|
||||
export async function generateBriefing(currentData, previousBriefing) {
|
||||
if (!currentData || Object.keys(currentData.sources || currentData).length === 0) {
|
||||
return 'No data available to generate briefing. Please check your data sources configuration.';
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = buildBriefingPrompt(currentData, previousBriefing);
|
||||
const briefing = await callClaude(prompt);
|
||||
return briefing;
|
||||
} catch (error) {
|
||||
console.error('Error generating briefing:', error);
|
||||
|
||||
// Return a fallback briefing
|
||||
return `Good morning! I encountered an issue generating your personalized briefing today.
|
||||
|
||||
Error: ${error.message}
|
||||
|
||||
Please check your API key configuration and try again.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to Claude
|
||||
* @param {string} message - User's message
|
||||
* @param {Array} conversationHistory - Previous messages
|
||||
* @returns {Promise<string>} Claude's response
|
||||
*/
|
||||
export async function sendChatMessage(message, conversationHistory = []) {
|
||||
if (!message || !message.trim()) {
|
||||
throw new Error('Message cannot be empty');
|
||||
}
|
||||
|
||||
// Build context from conversation history
|
||||
const messages = conversationHistory.map(msg => ({
|
||||
role: msg.role === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
// Add context about the dashboard
|
||||
const contextPrompt = `You are a helpful assistant embedded in a personal dashboard application.
|
||||
You have access to the user's daily briefing data and can help answer questions about news, weather,
|
||||
and other information sources configured in their dashboard. Be conversational and helpful.
|
||||
|
||||
User's message: ${message}`;
|
||||
|
||||
try {
|
||||
const response = await callClaude(contextPrompt, messages);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error in chat:', error);
|
||||
return `I apologize, but I encountered an error: ${error.message}. Please try again.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful content from HTML using Claude
|
||||
* @param {string} html - The HTML content
|
||||
* @param {string} url - The source URL
|
||||
* @returns {Promise<Object>} Extracted content
|
||||
*/
|
||||
export async function extractContentFromHTML(html, url) {
|
||||
const prompt = `Extract the main content from this HTML page.
|
||||
Focus on the primary article, news content, or main information.
|
||||
Ignore navigation, ads, and other peripheral content.
|
||||
|
||||
URL: ${url}
|
||||
|
||||
HTML (truncated to first 10000 chars):
|
||||
${html.substring(0, 10000)}
|
||||
|
||||
Please provide a JSON response with:
|
||||
- title: The main title
|
||||
- summary: A brief summary (2-3 sentences)
|
||||
- mainContent: The main text content (up to 500 words)
|
||||
- metadata: Any relevant metadata (author, date, etc.)
|
||||
|
||||
Respond with valid JSON only.`;
|
||||
|
||||
try {
|
||||
const response = await callClaude(prompt);
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(response);
|
||||
} catch {
|
||||
// If not valid JSON, return structured response
|
||||
return {
|
||||
title: 'Content from ' + url,
|
||||
summary: response.substring(0, 200),
|
||||
mainContent: response,
|
||||
metadata: { url, extractedAt: Date.now() }
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting HTML content:', error);
|
||||
return {
|
||||
error: true,
|
||||
message: error.message,
|
||||
url: url
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary of specific data
|
||||
* @param {Object} data - Data to summarize
|
||||
* @param {string} dataType - Type of data (reddit, news, etc.)
|
||||
* @returns {Promise<string>} Summary
|
||||
*/
|
||||
export async function summarizeData(data, dataType) {
|
||||
const prompt = `Summarize this ${dataType} data in 2-3 sentences, highlighting the most interesting or important items:
|
||||
|
||||
${JSON.stringify(data, null, 2)}
|
||||
|
||||
Be concise and focus on what would be most relevant to someone checking their daily news.`;
|
||||
|
||||
try {
|
||||
return await callClaude(prompt);
|
||||
} catch (error) {
|
||||
console.error(`Error summarizing ${dataType}:`, error);
|
||||
return `Unable to summarize ${dataType} data.`;
|
||||
}
|
||||
}
|
||||
231
claudash/src/services/dataFetcher.js
Normal file
231
claudash/src/services/dataFetcher.js
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { parseSource, standardizeSourceData } from './sourceParser';
|
||||
import { getCachedData, cacheSourceData } from './storageService';
|
||||
import { fetchWithCors } from '../utils/corsProxy';
|
||||
import {
|
||||
DEFAULT_DATA_SOURCES,
|
||||
WEATHER_API_BASE,
|
||||
DEFAULT_REFRESH_INTERVAL
|
||||
} from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Parse environment variable array
|
||||
* @param {string} envValue - Comma-separated values
|
||||
* @returns {Array} Parsed array
|
||||
*/
|
||||
function parseEnvArray(envValue) {
|
||||
if (!envValue) return [];
|
||||
return envValue.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from URL
|
||||
* @param {string} url - The URL
|
||||
* @returns {string} Cache key
|
||||
*/
|
||||
function getCacheKey(url) {
|
||||
return url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single source with caching
|
||||
* @param {string} url - Source URL
|
||||
* @returns {Promise<Object>} Source data
|
||||
*/
|
||||
export async function fetchSingleSource(url) {
|
||||
const cacheKey = getCacheKey(url);
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached data for ${url}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Fetching fresh data from ${url}`);
|
||||
const data = await parseSource(url);
|
||||
const standardized = standardizeSourceData(data, url);
|
||||
|
||||
// Cache the result
|
||||
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
|
||||
cacheSourceData(cacheKey, standardized, ttl);
|
||||
|
||||
return standardized;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${url}:`, error);
|
||||
// Try to return cached data even if expired
|
||||
const expiredCache = getCachedData(cacheKey);
|
||||
if (expiredCache) {
|
||||
console.log(`Returning expired cache for ${url} due to fetch error`);
|
||||
return expiredCache;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch weather data from wttr.in
|
||||
* @param {string} location - Location string (city, zip, etc.)
|
||||
* @returns {Promise<Object>} Weather data
|
||||
*/
|
||||
export async function fetchWeather(location) {
|
||||
if (!location) {
|
||||
console.warn('No location configured for weather');
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `weather_${location.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached weather data for ${location}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format location for wttr.in
|
||||
const formattedLocation = encodeURIComponent(location);
|
||||
const weatherUrl = `${WEATHER_API_BASE}/${formattedLocation}?format=j1`;
|
||||
|
||||
console.log(`Fetching weather for ${location}`);
|
||||
const data = await fetchWithCors(weatherUrl);
|
||||
|
||||
// Standardize weather data
|
||||
const weatherData = {
|
||||
type: 'weather',
|
||||
location: location,
|
||||
current: data.current_condition?.[0] || {},
|
||||
forecast: data.weather || [],
|
||||
nearestArea: data.nearest_area?.[0] || {},
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
|
||||
cacheSourceData(cacheKey, weatherData, ttl);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching weather for ${location}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from all configured sources
|
||||
* @returns {Promise<Object>} Object with all source data
|
||||
*/
|
||||
export async function fetchAllSources() {
|
||||
// Get configured sources from environment or use defaults
|
||||
const sourcesEnv = import.meta.env.REACT_APP_DATA_SOURCES;
|
||||
const sources = sourcesEnv ? parseEnvArray(sourcesEnv) : DEFAULT_DATA_SOURCES;
|
||||
|
||||
// Get location for weather
|
||||
const location = import.meta.env.REACT_APP_LOCATION;
|
||||
|
||||
console.log('Fetching from sources:', sources);
|
||||
console.log('Weather location:', location);
|
||||
|
||||
// Create fetch promises
|
||||
const promises = sources.map(url =>
|
||||
fetchSingleSource(url).catch(error => ({
|
||||
error: true,
|
||||
url: url,
|
||||
message: error.message
|
||||
}))
|
||||
);
|
||||
|
||||
// Add weather fetch if location is configured
|
||||
if (location) {
|
||||
promises.push(
|
||||
fetchWeather(location).catch(error => ({
|
||||
error: true,
|
||||
type: 'weather',
|
||||
message: error.message
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all in parallel
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
// Process results
|
||||
const sourceData = {};
|
||||
const errors = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const data = result.value;
|
||||
if (data.error) {
|
||||
errors.push(data);
|
||||
} else {
|
||||
// Assign to appropriate key
|
||||
if (data.type === 'weather') {
|
||||
sourceData.weather = data;
|
||||
} else if (data.type === 'reddit') {
|
||||
const key = `reddit_${Object.keys(sourceData).filter(k => k.startsWith('reddit')).length}`;
|
||||
sourceData[key] = data;
|
||||
} else if (data.type === 'hackernews') {
|
||||
sourceData.hackernews = data;
|
||||
} else if (data.type === 'rss') {
|
||||
const key = `rss_${Object.keys(sourceData).filter(k => k.startsWith('rss')).length}`;
|
||||
sourceData[key] = data;
|
||||
} else {
|
||||
const key = `source_${index}`;
|
||||
sourceData[key] = data;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
error: true,
|
||||
index: index,
|
||||
reason: result.reason?.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Log any errors
|
||||
if (errors.length > 0) {
|
||||
console.warn('Some sources failed to fetch:', errors);
|
||||
}
|
||||
|
||||
return {
|
||||
sources: sourceData,
|
||||
errors: errors,
|
||||
fetchedAt: Date.now(),
|
||||
successful: Object.keys(sourceData).length,
|
||||
failed: errors.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific Hacker News story by ID
|
||||
* @param {number} storyId - Story ID
|
||||
* @returns {Promise<Object>} Story data
|
||||
*/
|
||||
export async function fetchHackerNewsStory(storyId) {
|
||||
try {
|
||||
const url = `https://hacker-news.firebaseio.com/v0/item/${storyId}.json`;
|
||||
return await fetchWithCors(url);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching HN story ${storyId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple Hacker News stories
|
||||
* @param {Array<number>} storyIds - Array of story IDs
|
||||
* @param {number} limit - Maximum number of stories to fetch
|
||||
* @returns {Promise<Array>} Array of story data
|
||||
*/
|
||||
export async function fetchHackerNewsStories(storyIds, limit = 10) {
|
||||
const idsToFetch = storyIds.slice(0, limit);
|
||||
const promises = idsToFetch.map(id => fetchHackerNewsStory(id));
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
return results
|
||||
.filter(r => r.status === 'fulfilled' && r.value)
|
||||
.map(r => r.value);
|
||||
}
|
||||
192
claudash/src/services/sourceParser.js
Normal file
192
claudash/src/services/sourceParser.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { fetchWithCors } from '../utils/corsProxy';
|
||||
import { RSS_TO_JSON_API } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Determine source type from URL
|
||||
* @param {string} url - The URL to analyze
|
||||
* @returns {string} - 'json' | 'rss' | 'html'
|
||||
*/
|
||||
export function detectSourceType(url) {
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
if (urlLower.endsWith('.json')) return 'json';
|
||||
if (urlLower.endsWith('.rss') ||
|
||||
urlLower.endsWith('.xml') ||
|
||||
urlLower.includes('/feed') ||
|
||||
urlLower.includes('rss')) return 'rss';
|
||||
|
||||
return 'html'; // Default to HTML scraping
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON endpoint
|
||||
* @param {string} url - The JSON URL
|
||||
* @param {string} corsProxy - CORS proxy URL
|
||||
* @returns {Promise<any>} - Parsed JSON data
|
||||
*/
|
||||
export async function parseJSON(url, corsProxy) {
|
||||
try {
|
||||
const data = await fetchWithCors(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing JSON from ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RSS feed
|
||||
* @param {string} url - The RSS feed URL
|
||||
* @returns {Promise<any>} - Parsed RSS data
|
||||
*/
|
||||
export async function parseRSS(url) {
|
||||
try {
|
||||
// Use RSS to JSON service
|
||||
const rssUrl = `${RSS_TO_JSON_API}?rss_url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(rssUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RSS parse failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if RSS2JSON returned an error
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.message || 'RSS feed parsing failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing RSS from ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML content (fetch and prepare for Claude extraction)
|
||||
* @param {string} url - The HTML page URL
|
||||
* @param {string} corsProxy - CORS proxy URL
|
||||
* @returns {Promise<Object>} - Object with URL and HTML content
|
||||
*/
|
||||
export async function parseHTML(url, corsProxy) {
|
||||
try {
|
||||
const html = await fetchWithCors(url);
|
||||
|
||||
// Return structured data for Claude to process
|
||||
return {
|
||||
url: url,
|
||||
html: typeof html === 'string' ? html : JSON.stringify(html),
|
||||
type: 'html',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching HTML from ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a source based on its detected type
|
||||
* @param {string} url - The URL to parse
|
||||
* @param {string} type - Optional type override
|
||||
* @returns {Promise<any>} - Parsed data
|
||||
*/
|
||||
export async function parseSource(url, type = null) {
|
||||
const sourceType = type || detectSourceType(url);
|
||||
|
||||
switch(sourceType) {
|
||||
case 'json':
|
||||
return await parseJSON(url);
|
||||
|
||||
case 'rss':
|
||||
return await parseRSS(url);
|
||||
|
||||
case 'html':
|
||||
return await parseHTML(url);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown source type: ${sourceType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful content from HTML using Claude
|
||||
* This will be called later when Claude service is integrated
|
||||
* @param {string} html - The HTML content
|
||||
* @param {string} url - The source URL
|
||||
* @returns {Promise<Object>} - Extracted content
|
||||
*/
|
||||
export async function extractWithClaude(html, url) {
|
||||
// This function will be implemented when Claude service is ready
|
||||
// For now, return a placeholder structure
|
||||
return {
|
||||
url: url,
|
||||
content: html.substring(0, 5000), // Limit content for now
|
||||
extractedAt: Date.now(),
|
||||
needsClaudeProcessing: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean and standardize source data
|
||||
* @param {any} data - Raw source data
|
||||
* @param {string} sourceUrl - The source URL
|
||||
* @returns {Object} - Standardized data object
|
||||
*/
|
||||
export function standardizeSourceData(data, sourceUrl) {
|
||||
// Detect the source and format accordingly
|
||||
if (sourceUrl.includes('reddit.com') && data.data) {
|
||||
// Reddit JSON format
|
||||
return {
|
||||
type: 'reddit',
|
||||
url: sourceUrl,
|
||||
posts: data.data.children?.map(child => ({
|
||||
title: child.data.title,
|
||||
url: child.data.url,
|
||||
author: child.data.author,
|
||||
score: child.data.score,
|
||||
comments: child.data.num_comments,
|
||||
created: child.data.created_utc
|
||||
})) || [],
|
||||
after: data.data.after,
|
||||
before: data.data.before
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceUrl.includes('hacker-news') && Array.isArray(data)) {
|
||||
// Hacker News top stories (just IDs)
|
||||
return {
|
||||
type: 'hackernews',
|
||||
url: sourceUrl,
|
||||
storyIds: data.slice(0, 30), // Get top 30 stories
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
if (data.items && data.feed) {
|
||||
// RSS feed format from rss2json
|
||||
return {
|
||||
type: 'rss',
|
||||
url: sourceUrl,
|
||||
feedTitle: data.feed.title,
|
||||
feedUrl: data.feed.url,
|
||||
items: data.items.map(item => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
pubDate: item.pubDate,
|
||||
author: item.author,
|
||||
categories: item.categories
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// Default format for unknown sources
|
||||
return {
|
||||
type: 'unknown',
|
||||
url: sourceUrl,
|
||||
data: data,
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
}
|
||||
194
claudash/src/services/storageService.js
Normal file
194
claudash/src/services/storageService.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { STORAGE_KEY, DEFAULT_HISTORY_DAYS, CACHE_TTL } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Get the storage object from localStorage
|
||||
* @returns {Object} The storage object
|
||||
*/
|
||||
function getStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : { briefings: {}, cache: {} };
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return { briefings: {}, cache: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the storage object to localStorage
|
||||
* @param {Object} storage - The storage object to save
|
||||
*/
|
||||
function saveStorage(storage) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
|
||||
} catch (error) {
|
||||
console.error('Error saving to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save today's briefing
|
||||
* @param {string} date - Date in YYYY-MM-DD format
|
||||
* @param {string} briefingText - The generated briefing text
|
||||
* @param {Object} sourceData - Raw data from all sources
|
||||
*/
|
||||
export function saveBriefing(date, briefingText, sourceData) {
|
||||
const storage = getStorage();
|
||||
|
||||
storage.briefings[date] = {
|
||||
timestamp: Date.now(),
|
||||
briefing: briefingText,
|
||||
sources: sourceData
|
||||
};
|
||||
|
||||
saveStorage(storage);
|
||||
|
||||
// Clean up old data after saving
|
||||
cleanupOldData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a previous briefing
|
||||
* @param {number} daysAgo - How many days ago
|
||||
* @returns {Object|null} The briefing object or null if not found
|
||||
*/
|
||||
export function getPreviousBriefing(daysAgo = 1) {
|
||||
const storage = getStorage();
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
const dateKey = date.toISOString().split('T')[0];
|
||||
|
||||
return storage.briefings[dateKey] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache fetched source data with timestamp
|
||||
* @param {string} sourceKey - Unique key for the source
|
||||
* @param {any} data - Data to cache
|
||||
* @param {number} ttlMinutes - Time to live in minutes
|
||||
*/
|
||||
export function cacheSourceData(sourceKey, data, ttlMinutes = CACHE_TTL) {
|
||||
const storage = getStorage();
|
||||
|
||||
storage.cache[sourceKey] = {
|
||||
data: data,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttlMinutes * 60 * 1000 // Convert to milliseconds
|
||||
};
|
||||
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data if still fresh
|
||||
* @param {string} sourceKey - Unique key for the source
|
||||
* @returns {any|null} Cached data or null if expired/not found
|
||||
*/
|
||||
export function getCachedData(sourceKey) {
|
||||
const storage = getStorage();
|
||||
const cached = storage.cache[sourceKey];
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cached.timestamp;
|
||||
|
||||
if (age > cached.ttl) {
|
||||
// Cache expired
|
||||
delete storage.cache[sourceKey];
|
||||
saveStorage(storage);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old data (keep only last N days)
|
||||
* @param {number} daysToKeep - Number of days to keep
|
||||
*/
|
||||
export function cleanupOldData(daysToKeep = null) {
|
||||
const storage = getStorage();
|
||||
const days = daysToKeep || parseInt(import.meta.env.REACT_APP_HISTORY_DAYS) || DEFAULT_HISTORY_DAYS;
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
// Clean old briefings
|
||||
if (storage.briefings) {
|
||||
Object.keys(storage.briefings).forEach(date => {
|
||||
if (new Date(date) < cutoffDate) {
|
||||
delete storage.briefings[date];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean expired cache entries
|
||||
const now = Date.now();
|
||||
if (storage.cache) {
|
||||
Object.keys(storage.cache).forEach(key => {
|
||||
const cached = storage.cache[key];
|
||||
if (cached && (now - cached.timestamp) > cached.ttl) {
|
||||
delete storage.cache[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all data for debugging
|
||||
* @returns {Object} All stored data
|
||||
*/
|
||||
export function exportAllData() {
|
||||
return getStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import dummy data (dev mode)
|
||||
* @param {Object} jsonData - Data to import
|
||||
*/
|
||||
export function importDummyData(jsonData) {
|
||||
if (jsonData && typeof jsonData === 'object') {
|
||||
saveStorage(jsonData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored data
|
||||
*/
|
||||
export function clearAllData() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all briefings
|
||||
* @returns {Object} All briefings
|
||||
*/
|
||||
export function getAllBriefings() {
|
||||
const storage = getStorage();
|
||||
return storage.briefings || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a briefing for today
|
||||
* @returns {boolean} True if today's briefing exists
|
||||
*/
|
||||
export function hasTodaysBriefing() {
|
||||
const storage = getStorage();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return !!storage.briefings[today];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's briefing
|
||||
* @returns {Object|null} Today's briefing or null
|
||||
*/
|
||||
export function getTodaysBriefing() {
|
||||
const storage = getStorage();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return storage.briefings[today] || null;
|
||||
}
|
||||
32
claudash/src/theme.js
Normal file
32
claudash/src/theme.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const sharedTheme = {
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
};
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
...sharedTheme,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#90caf9' },
|
||||
secondary: { main: '#f48fb1' },
|
||||
background: {
|
||||
default: '#0a1929',
|
||||
paper: '#1e2a35',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
...sharedTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#1976d2' },
|
||||
secondary: { main: '#dc004e' },
|
||||
},
|
||||
});
|
||||
30
claudash/src/utils/constants.js
Normal file
30
claudash/src/utils/constants.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// App constants
|
||||
export const STORAGE_KEY = 'claudash';
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL = 15; // minutes
|
||||
export const DEFAULT_HISTORY_DAYS = 7;
|
||||
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-5-20250929';
|
||||
export const DEFAULT_CORS_PROXY = 'https://api.allorigins.win/raw?url=';
|
||||
|
||||
// Cache TTL in minutes
|
||||
export const CACHE_TTL = 15;
|
||||
|
||||
// Date format
|
||||
export const DATE_FORMAT = 'YYYY-MM-DD';
|
||||
|
||||
// API endpoints
|
||||
export const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
|
||||
export const RSS_TO_JSON_API = 'https://api.rss2json.com/v1/api.json';
|
||||
|
||||
// Default data sources (fallback if none provided)
|
||||
export const DEFAULT_DATA_SOURCES = [
|
||||
'https://www.reddit.com/r/technology.json',
|
||||
'https://hacker-news.firebaseio.com/v0/topstories.json',
|
||||
'https://www.reddit.com/r/worldnews.json'
|
||||
];
|
||||
|
||||
// Weather API
|
||||
export const WEATHER_API_BASE = 'https://wttr.in';
|
||||
|
||||
// Max tokens for Claude responses
|
||||
export const MAX_TOKENS = 1024;
|
||||
88
claudash/src/utils/corsProxy.js
Normal file
88
claudash/src/utils/corsProxy.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { DEFAULT_CORS_PROXY } from './constants';
|
||||
|
||||
/**
|
||||
* Fetch data with CORS proxy fallback
|
||||
* @param {string} url - The URL to fetch
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<any>} - The fetched data
|
||||
*/
|
||||
export async function fetchWithCors(url, options = {}) {
|
||||
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
|
||||
|
||||
try {
|
||||
// Try direct fetch first
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
// If CORS fails, try with proxy
|
||||
console.warn(`CORS failed for ${url}, trying proxy...`, error.message);
|
||||
|
||||
try {
|
||||
const proxiedUrl = `${proxy}${encodeURIComponent(url)}`;
|
||||
const response = await fetch(proxiedUrl, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (proxyError) {
|
||||
console.error(`Both direct and proxy fetch failed for ${url}:`, proxyError);
|
||||
throw proxyError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a URL is accessible directly without CORS issues
|
||||
* @param {string} url - The URL to test
|
||||
* @returns {Promise<boolean>} - True if accessible, false otherwise
|
||||
*/
|
||||
export async function testCorsAccess(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
mode: 'cors',
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate URL (direct or proxied) based on CORS accessibility
|
||||
* @param {string} url - The original URL
|
||||
* @returns {Promise<string>} - Either the original URL or a proxied version
|
||||
*/
|
||||
export async function getAccessibleUrl(url) {
|
||||
const canAccess = await testCorsAccess(url);
|
||||
if (canAccess) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
|
||||
return `${proxy}${encodeURIComponent(url)}`;
|
||||
}
|
||||
36
claudash/vite.config.js
Normal file
36
claudash/vite.config.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// Expose REACT_APP_ prefixed variables to import.meta.env
|
||||
const processEnvValues = {
|
||||
'process.env': Object.entries(env).reduce(
|
||||
(prev, [key, val]) => {
|
||||
return {
|
||||
...prev,
|
||||
[key]: val,
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
define: {
|
||||
// Expose all REACT_APP_ prefixed variables
|
||||
'import.meta.env.REACT_APP_ANTHROPIC_API_KEY': JSON.stringify(env.REACT_APP_ANTHROPIC_API_KEY),
|
||||
'import.meta.env.REACT_APP_DATA_SOURCES': JSON.stringify(env.REACT_APP_DATA_SOURCES),
|
||||
'import.meta.env.REACT_APP_LOCATION': JSON.stringify(env.REACT_APP_LOCATION),
|
||||
'import.meta.env.REACT_APP_CORS_PROXY': JSON.stringify(env.REACT_APP_CORS_PROXY),
|
||||
'import.meta.env.REACT_APP_REFRESH_INTERVAL': JSON.stringify(env.REACT_APP_REFRESH_INTERVAL),
|
||||
'import.meta.env.REACT_APP_HISTORY_DAYS': JSON.stringify(env.REACT_APP_HISTORY_DAYS),
|
||||
'import.meta.env.REACT_APP_CLAUDE_MODEL': JSON.stringify(env.REACT_APP_CLAUDE_MODEL),
|
||||
'import.meta.env.REACT_APP_DEBUG_MODE': JSON.stringify(env.REACT_APP_DEBUG_MODE),
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue