diff --git a/claudash/.env.example b/claudash/.env.example
index 621da7f..8f2be83 100644
--- a/claudash/.env.example
+++ b/claudash/.env.example
@@ -7,7 +7,7 @@ REACT_APP_ANTHROPIC_API_KEY=sk-ant-your-key-here
# Example formats:
# - Object format: {"row1":["url1","url2"],"row2":["url3","url4"]}
# - Legacy format: url1,url2,url3 (comma-separated)
-REACT_APP_DATA_SOURCES={"row1":["https://www.reddit.com/r/technology.json","https://www.reddit.com/r/worldnews.json"],"row2":["https://www.reddit.com/r/science.json"]}
+REACT_APP_DATA_SOURCES={"row1":["https://www.reddit.com/r/technology.json","https://www.reddit.com/r/worldnews.json"],"row2":["https://www.reddit.com/r/science.json"],"Games":["https://www.elitedangerous.com/en-US/UpdateNotes"]}
# Location for weather (supports "city, state, country" or zip code)
REACT_APP_LOCATION=harrisburg, pa, usa
@@ -39,3 +39,6 @@ REACT_APP_CHART_DATE_FORMAT=MM/DD/YYYY
# Optional: Default text-to-speech voice (must match exact voice name from browser)
REACT_APP_TEXT_VOICE=Google UK English Female
+
+# Optional: Quick links (comma-separated URLs displayed as buttons in a bar at the top)
+REACT_APP_QUICK_LINKS=https://github.com/Drewpeifer,https://drewpeifer.com/,https://www.amazon.com/,https://radio.garden/,https://cloudhiker.net/
diff --git a/claudash/proxy-server.js b/claudash/proxy-server.js
index 8fac783..2770c61 100644
--- a/claudash/proxy-server.js
+++ b/claudash/proxy-server.js
@@ -55,7 +55,42 @@ app.post('/api/claude', async (req, res) => {
}
});
+// Proxy endpoint for scraping web pages (avoids CORS issues)
+app.get('/api/scrape', async (req, res) => {
+ try {
+ const { url } = req.query;
+
+ if (!url) {
+ return res.status(400).json({ error: 'Missing url query parameter' });
+ }
+
+ console.log('=== Scrape proxy request ===');
+ console.log('URL:', url);
+
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ });
+
+ if (!response.ok) {
+ console.error('Scrape failed:', response.status, response.statusText);
+ return res.status(response.status).json({ error: `Failed to fetch: ${response.status} ${response.statusText}` });
+ }
+
+ const html = await response.text();
+ console.log('Scraped HTML length:', html.length);
+ res.type('text/html').send(html);
+ } catch (error) {
+ console.error('Scrape 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
+ console.log('Scrape endpoint available at GET /api/scrape?url=...');
+});
diff --git a/claudash/src/App.css b/claudash/src/App.css
index 840beb3..e230d2b 100644
--- a/claudash/src/App.css
+++ b/claudash/src/App.css
@@ -411,3 +411,73 @@ input, textarea {
.app-light .debug-warning {
color: #e65100;
}
+
+/* Quick Links Bar */
+.quick-links-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 6px 12px;
+ background-color: rgba(0, 0, 0, 0.3);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ align-items: center;
+}
+
+.app-light .quick-links-bar {
+ background-color: rgba(0, 0, 0, 0.04);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+}
+
+.quick-link-btn {
+ display: inline-block;
+ padding: 3px 10px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ border-radius: 4px;
+ background-color: rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.7);
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 180px;
+ transition: background-color 0.2s, color 0.2s;
+}
+
+.quick-link-btn:hover {
+ background-color: rgba(255, 255, 255, 0.16);
+ color: #fff;
+}
+
+.app-light .quick-link-btn {
+ background-color: rgba(0, 0, 0, 0.06);
+ color: rgba(0, 0, 0, 0.6);
+}
+
+.app-light .quick-link-btn:hover {
+ background-color: rgba(0, 0, 0, 0.12);
+ color: rgba(0, 0, 0, 0.87);
+}
+
+/* Webpage Widget Styles */
+.webpage-summary {
+ font-size: 0.875rem;
+ color: rgba(255, 255, 255, 0.7);
+ line-height: 1.5;
+ margin: 0 0 12px 0;
+}
+
+.app-light .webpage-summary {
+ color: rgba(0, 0, 0, 0.6);
+}
+
+.webpage-item-summary {
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.6);
+ line-height: 1.4;
+ margin-top: 4px;
+}
+
+.app-light .webpage-item-summary {
+ color: rgba(0, 0, 0, 0.54);
+}
diff --git a/claudash/src/components/Dashboard.jsx b/claudash/src/components/Dashboard.jsx
index f7820c2..e84a972 100644
--- a/claudash/src/components/Dashboard.jsx
+++ b/claudash/src/components/Dashboard.jsx
@@ -8,6 +8,7 @@ import BriefingWidget from './widgets/BriefingWidget';
import WeatherWidget from './widgets/WeatherWidget';
import NewsWidget from './widgets/NewsWidget';
import ChatInterface from './ChatInterface';
+import QuickLinksBar from './QuickLinksBar';
function Dashboard({ themeToggle, isDark }) {
const [loading, setLoading] = useState(true);
@@ -16,7 +17,8 @@ function Dashboard({ themeToggle, isDark }) {
const [sourceData, setSourceData] = useState({});
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
- const [tokenUsage, setTokenUsage] = useState(null);
+ const [briefingTokenUsage, setBriefingTokenUsage] = useState(null);
+ const [webpageTokenUsage, setWebpageTokenUsage] = useState(null);
const [cacheInfo, setCacheInfo] = useState(null);
const isLoadingRef = useRef(false);
@@ -57,7 +59,8 @@ function Dashboard({ themeToggle, isDark }) {
age: cacheAgeMinutes,
timestamp: todaysBriefing.timestamp
});
- setTokenUsage(null); // Clear token usage when using cache
+ setBriefingTokenUsage(null); // Clear token usage when using cache
+ setWebpageTokenUsage(null);
setLoading(false);
return;
@@ -71,6 +74,11 @@ function Dashboard({ themeToggle, isDark }) {
console.log('Fetched sources:', Object.keys(data.sources || {}));
console.log('Failed sources:', data.failed);
setSourceData(data.sources || {});
+
+ // Track webpage summarization token usage
+ if (data.webpageTokenUsage) {
+ setWebpageTokenUsage(data.webpageTokenUsage);
+ }
// If all sources failed, show error but continue
if (data.failed > 0 && data.successful === 0) {
@@ -108,9 +116,9 @@ function Dashboard({ themeToggle, isDark }) {
// Handle result object with text, condensedSummary and usage
if (result && typeof result === 'object' && result.text) {
setBriefing(result.text);
- setTokenUsage(result.usage);
+ setBriefingTokenUsage(result.usage);
setCacheInfo(null); // Clear cache info when generating fresh briefing
- console.log('Token usage:', result.usage);
+ console.log('Briefing token usage:', result.usage);
} else if (typeof result === 'string') {
// Fallback for string responses
setBriefing(result);
@@ -203,16 +211,38 @@ function Dashboard({ themeToggle, isDark }) {
)}
- {tokenUsage && (
+ {(briefingTokenUsage || webpageTokenUsage) && (
- API Usage: {tokenUsage.input_tokens?.toLocaleString()} input + {tokenUsage.output_tokens?.toLocaleString()} output = {(tokenUsage.input_tokens + tokenUsage.output_tokens).toLocaleString()} total tokens
- {' • '}
- Cost: ${((tokenUsage.input_tokens * 0.003 / 1000) + (tokenUsage.output_tokens * 0.015 / 1000)).toFixed(4)}
- {' '}
-
- (Claude Sonnet 4 pricing: $0.003/1K input, $0.015/1K output)
-
+ Total API Usage This Refresh:
+
+
+ {briefingTokenUsage && (
+
+ • Briefing Generation: {briefingTokenUsage.input_tokens?.toLocaleString()} input + {briefingTokenUsage.output_tokens?.toLocaleString()} output = {(briefingTokenUsage.input_tokens + briefingTokenUsage.output_tokens).toLocaleString()} tokens
+ {' • '}
+ Cost: ${((briefingTokenUsage.input_tokens * 0.003 / 1000) + (briefingTokenUsage.output_tokens * 0.015 / 1000)).toFixed(4)}
+
+ )}
+
+ {webpageTokenUsage && (
+
+ • Webpage Summarization: {webpageTokenUsage.input_tokens?.toLocaleString()} input + {webpageTokenUsage.output_tokens?.toLocaleString()} output = {(webpageTokenUsage.input_tokens + webpageTokenUsage.output_tokens).toLocaleString()} tokens
+ {' • '}
+ Cost: ${((webpageTokenUsage.input_tokens * 0.003 / 1000) + (webpageTokenUsage.output_tokens * 0.015 / 1000)).toFixed(4)}
+
+ )}
+
+ {briefingTokenUsage && webpageTokenUsage && (
+
+ Combined Total: {((briefingTokenUsage.input_tokens + webpageTokenUsage.input_tokens)).toLocaleString()} input + {((briefingTokenUsage.output_tokens + webpageTokenUsage.output_tokens)).toLocaleString()} output = {((briefingTokenUsage.input_tokens + briefingTokenUsage.output_tokens + webpageTokenUsage.input_tokens + webpageTokenUsage.output_tokens)).toLocaleString()} tokens
+ {' • '}
+ Total Cost: ${(((briefingTokenUsage.input_tokens + webpageTokenUsage.input_tokens) * 0.003 / 1000) + ((briefingTokenUsage.output_tokens + webpageTokenUsage.output_tokens) * 0.015 / 1000)).toFixed(4)}
+
+ )}
+
+
+ (Claude Sonnet 4 pricing: $0.003/1K input, $0.015/1K output)
)}
@@ -230,6 +260,7 @@ function Dashboard({ themeToggle, isDark }) {
)}
+
s.trim()).filter(Boolean);
+
+ if (links.length === 0) return null;
+
+ /**
+ * Parse a URL into a short, readable label.
+ * - For paths like "https://github.com/Drewpeifer" → "github/Drewpeifer"
+ * - For bare domains like "https://radio.garden/" → "radio.garden"
+ * - Strips www. prefix for brevity
+ */
+ function parseLabel(url) {
+ try {
+ const parsed = new URL(url);
+ let host = parsed.hostname.replace(/^www\./, '');
+ const path = parsed.pathname.replace(/\/+$/, ''); // trim trailing slashes
+
+ if (path && path !== '') {
+ // Has a meaningful path — include host (without TLD) + path
+ const hostBase = host.replace(/\.[^.]+$/, ''); // strip last TLD segment
+ return `${hostBase}${path}`;
+ }
+ // No path — just return the host
+ return host;
+ } catch {
+ return url;
+ }
+ }
+
+ return (
+
+ );
+}
+
+export default QuickLinksBar;
diff --git a/claudash/src/components/widgets/NewsWidget.jsx b/claudash/src/components/widgets/NewsWidget.jsx
index c4a90d3..3934f77 100644
--- a/claudash/src/components/widgets/NewsWidget.jsx
+++ b/claudash/src/components/widgets/NewsWidget.jsx
@@ -198,6 +198,67 @@ function NewsWidget({ data, sx }) {
}
+ // Handle webpage / scraped content (Claude-summarized)
+ if (data.type === 'webpage') {
+ const title = data.title || new URL(data.url).hostname;
+ const contentType = data.contentType || 'Webpage';
+
+ return (
+
+ }
+ >
+ {data.summary && (
+ {data.summary}
+ )}
+
+ {data.items && data.items.length > 0 && (
+
+ {data.items.map((item, index) => (
+
+
+
+ {item.title}
+
+
+ }
+ secondaryTypographyProps={{ component: 'div' }}
+ secondary={
+
+ {item.date && (
+ {item.date}
+ )}
+ {item.summary && (
+ {item.summary}
+ )}
+
+ }
+ />
+
+ ))}
+
+ )}
+
+
+
+ Visit source
+
+
+
+ );
+ }
+
// Default fallback for unknown data types
return (
diff --git a/claudash/src/services/claudeService.js b/claudash/src/services/claudeService.js
index ecab7b8..52aee52 100644
--- a/claudash/src/services/claudeService.js
+++ b/claudash/src/services/claudeService.js
@@ -270,51 +270,96 @@ export async function sendChatMessage(message, conversationHistory = []) {
}
/**
- * Extract meaningful content from HTML using Claude
+ * Summarize scraped webpage content using Claude.
+ * Produces a structured result with a title, overall summary, and
+ * individual items (e.g., blog posts, update notes, products, etc.)
+ * contextualized to whatever the page content actually is.
+ *
+ * @param {string} cleanedText - Pre-cleaned text content (HTML stripped)
+ * @param {string} url - The source URL (used for context)
+ * @returns {Promise