diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ed804a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env.local +.DS_Store +node_modules/ +dist/ +package-lock.json +CLAUDE.md \ No newline at end of file diff --git a/claudash/.env.example b/claudash/.env.example new file mode 100644 index 0000000..b8ecd60 --- /dev/null +++ b/claudash/.env.example @@ -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 \ No newline at end of file diff --git a/claudash/.gitignore b/claudash/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/claudash/.gitignore @@ -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? diff --git a/claudash/README.md b/claudash/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/claudash/README.md @@ -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. diff --git a/claudash/eslint.config.js b/claudash/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/claudash/eslint.config.js @@ -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_]' }], + }, + }, +]) diff --git a/claudash/index.html b/claudash/index.html new file mode 100644 index 0000000..0654a82 --- /dev/null +++ b/claudash/index.html @@ -0,0 +1,13 @@ + + + + + + + claudash + + +
+ + + diff --git a/claudash/package.json b/claudash/package.json new file mode 100644 index 0000000..dfda0cc --- /dev/null +++ b/claudash/package.json @@ -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" + } +} diff --git a/claudash/proxy-server.js b/claudash/proxy-server.js new file mode 100644 index 0000000..72abfe4 --- /dev/null +++ b/claudash/proxy-server.js @@ -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'); +}); \ No newline at end of file diff --git a/claudash/public/vite.svg b/claudash/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/claudash/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/claudash/src/App.css b/claudash/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/claudash/src/App.css @@ -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; +} diff --git a/claudash/src/App.jsx b/claudash/src/App.jsx new file mode 100644 index 0000000..c418a28 --- /dev/null +++ b/claudash/src/App.jsx @@ -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 ( + + + setIsDark(!isDark)} + isDark={isDark} + /> + {debugMode && } + + ); +} + +export default App; \ No newline at end of file diff --git a/claudash/src/assets/react.svg b/claudash/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/claudash/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/claudash/src/components/ChatInterface.jsx b/claudash/src/components/ChatInterface.jsx new file mode 100644 index 0000000..79e4b2c --- /dev/null +++ b/claudash/src/components/ChatInterface.jsx @@ -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 ( + + {/* Expand/Collapse Button */} + + setIsExpanded(!isExpanded)} + size="small" + > + {isExpanded ? : } + + + Chat with Claude + + + + {/* Messages area - only visible when expanded */} + {isExpanded && ( + + {messages.length === 0 && ( + + Ask me anything about your briefing or the news... + + )} + {messages.map((msg, idx) => ( + + + {msg.content} + + + ))} +
+ + )} + + {/* Input area - always visible */} + + setInput(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + placeholder="Ask Claude anything..." + disabled={loading} + size="small" + /> + + + + + + ); +} + +export default ChatInterface; \ No newline at end of file diff --git a/claudash/src/components/Dashboard.jsx b/claudash/src/components/Dashboard.jsx new file mode 100644 index 0000000..b5b3a40 --- /dev/null +++ b/claudash/src/components/Dashboard.jsx @@ -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 ( + + + + ); + } + + return ( + + {/* Top toolbar */} + + { + localStorage.clear(); + window.location.reload(); + }} + title="Clear all cache and reload" + color="error" + > + 🗑️ + + + + + + {isDark ? : } + + + + + {error && ( + + {error} + + )} + + {/* Widget Grid - easily rearrangeable */} + + + + + + {/* Render NewsWidget for each news source */} + {Object.entries(sourceData).map(([key, data]) => { + if (key === 'weather') return null; + return ( + + ); + })} + + + + {/* Fixed chat at bottom */} + + + ); +} + +export default Dashboard; \ No newline at end of file diff --git a/claudash/src/components/DebugPanel.jsx b/claudash/src/components/DebugPanel.jsx new file mode 100644 index 0000000..5549954 --- /dev/null +++ b/claudash/src/components/DebugPanel.jsx @@ -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 ( + + Debug Tools + + + + + + {dummyMode && ( + + Using dummy data + + )} + + ); +} + +export default DebugPanel; \ No newline at end of file diff --git a/claudash/src/components/widgets/BriefingWidget.jsx b/claudash/src/components/widgets/BriefingWidget.jsx new file mode 100644 index 0000000..95b83b6 --- /dev/null +++ b/claudash/src/components/widgets/BriefingWidget.jsx @@ -0,0 +1,36 @@ +import { Typography, Skeleton, Box } from '@mui/material'; +import GenericWidget from './GenericWidget'; + +function BriefingWidget({ briefing, sx }) { + return ( + + + {briefing ? ( + + {briefing} + + ) : ( + <> + + + + + + + )} + + + ); +} + +export default BriefingWidget; \ No newline at end of file diff --git a/claudash/src/components/widgets/GenericWidget.jsx b/claudash/src/components/widgets/GenericWidget.jsx new file mode 100644 index 0000000..85a408c --- /dev/null +++ b/claudash/src/components/widgets/GenericWidget.jsx @@ -0,0 +1,22 @@ +import { Paper, Typography, Box } from '@mui/material'; + +function GenericWidget({ title, data, children, sx, ...props }) { + return ( + + {title} + + {children || {JSON.stringify(data)}} + + + ); +} + +export default GenericWidget; \ No newline at end of file diff --git a/claudash/src/components/widgets/NewsWidget.jsx b/claudash/src/components/widgets/NewsWidget.jsx new file mode 100644 index 0000000..969abb5 --- /dev/null +++ b/claudash/src/components/widgets/NewsWidget.jsx @@ -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 ( + + + {data?.message || 'News data unavailable'} + + + ); + } + + // Handle Reddit data + if (data.type === 'reddit' && data.posts) { + const subreddit = data.url?.match(/\/r\/([^\/\.]+)/)?.[1] || 'Reddit'; + + return ( + + + {data.posts.slice(0, 5).map((post, index) => ( + + + + + {post.title} + + + + + } + secondaryTypographyProps={{ component: 'div' }} + secondary={ + + } + label={post.score} + size="small" + variant="outlined" + /> + } + label={post.comments} + size="small" + variant="outlined" + /> + + by {post.author} + + + } + /> + + ))} + + + ); + } + + // Handle RSS feed data + if (data.type === 'rss' && data.items) { + return ( + + + {data.items.slice(0, 5).map((item, index) => ( + + + + {item.title} + + + + } + secondaryTypographyProps={{ component: 'div' }} + secondary={ + + + {item.pubDate && new Date(item.pubDate).toLocaleDateString()} + + {item.description && ( + + {item.description.replace(/<[^>]*>?/gm, '')} + + )} + + } + /> + + ))} + + + ); + } + + // Handle Hacker News data + if (data.type === 'hackernews' && data.storyIds) { + return ( + + + {data.storyIds.length} top stories loaded + + + (Story details will load when API is configured) + + + ); + } + + // Default fallback for unknown data types + return ( + + + Data format not recognized + + + {JSON.stringify(data).substring(0, 100)}... + + + ); +} + +export default NewsWidget; \ No newline at end of file diff --git a/claudash/src/components/widgets/WeatherWidget.jsx b/claudash/src/components/widgets/WeatherWidget.jsx new file mode 100644 index 0000000..8a05594 --- /dev/null +++ b/claudash/src/components/widgets/WeatherWidget.jsx @@ -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 ; + if (desc.includes('cloud') || desc.includes('overcast')) return ; + if (desc.includes('rain') || desc.includes('storm')) return ; + if (desc.includes('snow') || desc.includes('ice')) return ; + return ; + }; + + if (!data || data.error) { + return ( + + + {data?.message || 'Weather data unavailable'} + + + ); + } + + 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 ( + + + + + {getWeatherIcon(description)} + + + + {temp} + + + {tempUnit} + + + + + + {description} + + + + + + + + {data.forecast && data.forecast.length > 0 && ( + + + Forecast + + + {data.forecast.slice(0, 3).map((day, index) => ( + + + Day {index + 1} + + + {day.maxtempF || day.maxtempC}° / {day.mintempF || day.mintempC}° + + + ))} + + + )} + + + ); +} + +export default WeatherWidget; \ No newline at end of file diff --git a/claudash/src/index.css b/claudash/src/index.css new file mode 100644 index 0000000..08a3ac9 --- /dev/null +++ b/claudash/src/index.css @@ -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; + } +} diff --git a/claudash/src/main.jsx b/claudash/src/main.jsx new file mode 100644 index 0000000..6161d18 --- /dev/null +++ b/claudash/src/main.jsx @@ -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( + + + , +) \ No newline at end of file diff --git a/claudash/src/services/claudeService.js b/claudash/src/services/claudeService.js new file mode 100644 index 0000000..4c3d7c6 --- /dev/null +++ b/claudash/src/services/claudeService.js @@ -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} 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} 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} 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} 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} 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.`; + } +} \ No newline at end of file diff --git a/claudash/src/services/dataFetcher.js b/claudash/src/services/dataFetcher.js new file mode 100644 index 0000000..7a2aecd --- /dev/null +++ b/claudash/src/services/dataFetcher.js @@ -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} 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} 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 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} 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} storyIds - Array of story IDs + * @param {number} limit - Maximum number of stories to fetch + * @returns {Promise} 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); +} \ No newline at end of file diff --git a/claudash/src/services/sourceParser.js b/claudash/src/services/sourceParser.js new file mode 100644 index 0000000..f9d943d --- /dev/null +++ b/claudash/src/services/sourceParser.js @@ -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} - 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} - 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 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} - 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} - 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() + }; +} \ No newline at end of file diff --git a/claudash/src/services/storageService.js b/claudash/src/services/storageService.js new file mode 100644 index 0000000..a2f58cb --- /dev/null +++ b/claudash/src/services/storageService.js @@ -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; +} \ No newline at end of file diff --git a/claudash/src/theme.js b/claudash/src/theme.js new file mode 100644 index 0000000..3eb6710 --- /dev/null +++ b/claudash/src/theme.js @@ -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' }, + }, +}); \ No newline at end of file diff --git a/claudash/src/utils/constants.js b/claudash/src/utils/constants.js new file mode 100644 index 0000000..276c1d1 --- /dev/null +++ b/claudash/src/utils/constants.js @@ -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; \ No newline at end of file diff --git a/claudash/src/utils/corsProxy.js b/claudash/src/utils/corsProxy.js new file mode 100644 index 0000000..3399f09 --- /dev/null +++ b/claudash/src/utils/corsProxy.js @@ -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} - 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} - 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} - 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)}`; +} \ No newline at end of file diff --git a/claudash/vite.config.js b/claudash/vite.config.js new file mode 100644 index 0000000..c914a58 --- /dev/null +++ b/claudash/vite.config.js @@ -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), + } + } +}) \ No newline at end of file