fix(refactor): Cleanup components for improved readability and consistency

This commit is contained in:
Willie Zutz 2025-07-19 11:34:56 -06:00
parent 1228beb59a
commit 1b0c2c59b8
10 changed files with 590 additions and 350 deletions

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getWebContent, getWebContentLite } from '@/lib/utils/documents'; import { getWebContent, getWebContentLite } from '@/lib/utils/documents';
import { Document } from '@langchain/core/documents';
import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai'; import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages'; import { HumanMessage } from '@langchain/core/messages';
@ -10,6 +11,7 @@ import {
getCustomOpenaiModelName, getCustomOpenaiModelName,
} from '@/lib/config'; } from '@/lib/config';
import { ChatOllama } from '@langchain/ollama'; import { ChatOllama } from '@langchain/ollama';
import axios from 'axios';
interface Source { interface Source {
url: string; url: string;
@ -24,7 +26,9 @@ interface WidgetProcessRequest {
} }
// Helper function to fetch content from a single source // Helper function to fetch content from a single source
async function fetchSourceContent(source: Source): Promise<{ content: string; error?: string }> { async function fetchSourceContent(
source: Source,
): Promise<{ content: string; error?: string }> {
try { try {
let document; let document;
@ -33,13 +37,17 @@ async function fetchSourceContent(source: Source): Promise<{ content: string; er
document = await getWebContent(source.url); document = await getWebContent(source.url);
} else { } else {
// Use faster fetch for HTTP data/APIs // Use faster fetch for HTTP data/APIs
document = await getWebContentLite(source.url); const response = await axios.get(source.url, { transformResponse: [] });
document = new Document({
pageContent: response.data || '',
metadata: { source: source.url },
});
} }
if (!document) { if (!document) {
return { return {
content: '', content: '',
error: `Failed to fetch content from ${source.url}` error: `Failed to fetch content from ${source.url}`,
}; };
} }
@ -48,20 +56,26 @@ async function fetchSourceContent(source: Source): Promise<{ content: string; er
console.error(`Error fetching content from ${source.url}:`, error); console.error(`Error fetching content from ${source.url}:`, error);
return { return {
content: '', content: '',
error: `Error fetching ${source.url}: ${error instanceof Error ? error.message : 'Unknown error'}` error: `Error fetching ${source.url}: ${error instanceof Error ? error.message : 'Unknown error'}`,
}; };
} }
} }
// Helper function to replace variables in prompt // Helper function to replace variables in prompt
function replacePromptVariables(prompt: string, sourceContents: string[], location?: string): string { function replacePromptVariables(
prompt: string,
sourceContents: string[],
location?: string,
): string {
let processedPrompt = prompt; let processedPrompt = prompt;
// Replace source content variables // Replace source content variables
sourceContents.forEach((content, index) => { sourceContents.forEach((content, index) => {
const variable = `{{source_content_${index + 1}}}`; const variable = `{{source_content_${index + 1}}}`;
processedPrompt = processedPrompt.replace(new RegExp(variable, 'g'), content); processedPrompt = processedPrompt.replace(
new RegExp(variable, 'g'),
content,
);
}); });
// Replace location if provided // Replace location if provided
@ -73,7 +87,10 @@ function replacePromptVariables(prompt: string, sourceContents: string[], locati
} }
// Helper function to get LLM instance based on provider and model // Helper function to get LLM instance based on provider and model
async function getLLMInstance(provider: string, model: string): Promise<BaseChatModel | null> { async function getLLMInstance(
provider: string,
model: string,
): Promise<BaseChatModel | null> {
try { try {
const chatModelProviders = await getAvailableChatModelProviders(); const chatModelProviders = await getAvailableChatModelProviders();
@ -106,7 +123,11 @@ async function getLLMInstance(provider: string, model: string): Promise<BaseChat
} }
// Helper function to process the prompt with LLM // Helper function to process the prompt with LLM
async function processWithLLM(prompt: string, provider: string, model: string): Promise<string> { async function processWithLLM(
prompt: string,
provider: string,
model: string,
): Promise<string> {
const llm = await getLLMInstance(provider, model); const llm = await getLLMInstance(provider, model);
if (!llm) { if (!llm) {
@ -127,7 +148,7 @@ export async function POST(request: NextRequest) {
if (!body.sources || !body.prompt || !body.provider || !body.model) { if (!body.sources || !body.prompt || !body.provider || !body.model) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields: sources, prompt, provider, model' }, { error: 'Missing required fields: sources, prompt, provider, model' },
{ status: 400 } { status: 400 },
); );
} }
@ -135,19 +156,21 @@ export async function POST(request: NextRequest) {
if (!Array.isArray(body.sources) || body.sources.length === 0) { if (!Array.isArray(body.sources) || body.sources.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'At least one source URL is required' }, { error: 'At least one source URL is required' },
{ status: 400 } { status: 400 },
); );
} }
// Fetch content from all sources // Fetch content from all sources
console.log(`Processing widget with ${body.sources.length} source(s)`); console.log(`Processing widget with ${body.sources.length} source(s)`);
const sourceResults = await Promise.all( const sourceResults = await Promise.all(
body.sources.map(source => fetchSourceContent(source)) body.sources.map((source) => fetchSourceContent(source)),
); );
// Check for fetch errors // Check for fetch errors
const fetchErrors = sourceResults const fetchErrors = sourceResults
.map((result, index) => result.error ? `Source ${index + 1}: ${result.error}` : null) .map((result, index) =>
result.error ? `Source ${index + 1}: ${result.error}` : null,
)
.filter(Boolean); .filter(Boolean);
if (fetchErrors.length > 0) { if (fetchErrors.length > 0) {
@ -155,31 +178,36 @@ export async function POST(request: NextRequest) {
} }
// Extract successful content // Extract successful content
const sourceContents = sourceResults.map(result => result.content); const sourceContents = sourceResults.map((result) => result.content);
// If all sources failed, return error // If all sources failed, return error
if (sourceContents.every(content => !content)) { if (sourceContents.every((content) => !content)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch content from all sources' }, { error: 'Failed to fetch content from all sources' },
{ status: 500 } { status: 500 },
); );
} }
// Replace variables in prompt // Replace variables in prompt
const processedPrompt = replacePromptVariables(body.prompt, sourceContents); const processedPrompt = replacePromptVariables(body.prompt, sourceContents);
console.log('Processed prompt:', processedPrompt.substring(0, 200) + '...'); console.log('Processing prompt:', processedPrompt);
// Process with LLM // Process with LLM
try { try {
const llmResponse = await processWithLLM(processedPrompt, body.provider, body.model); const llmResponse = await processWithLLM(
processedPrompt,
body.provider,
body.model,
);
console.log('LLM response:', llmResponse);
return NextResponse.json({ return NextResponse.json({
content: llmResponse, content: llmResponse,
success: true, success: true,
sourcesFetched: sourceContents.filter(content => content).length, sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length, totalSources: body.sources.length,
warnings: fetchErrors.length > 0 ? fetchErrors : undefined warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
}); });
} catch (llmError) { } catch (llmError) {
console.error('LLM processing failed:', llmError); console.error('LLM processing failed:', llmError);
@ -193,24 +221,26 @@ export async function POST(request: NextRequest) {
${processedPrompt} ${processedPrompt}
## Sources Successfully Fetched ## Sources Successfully Fetched
${sourceContents.filter(content => content).length} of ${body.sources.length} sources ${sourceContents.filter((content) => content).length} of ${body.sources.length} sources
${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`; ${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
return NextResponse.json({ return NextResponse.json({
content: diagnosticResponse, content: diagnosticResponse,
success: false, success: false,
error: llmError instanceof Error ? llmError.message : 'LLM processing failed', error:
sourcesFetched: sourceContents.filter(content => content).length, llmError instanceof Error
totalSources: body.sources.length ? llmError.message
: 'LLM processing failed',
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
}); });
} }
} catch (error) { } catch (error) {
console.error('Error processing widget:', error); console.error('Error processing widget:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 },
); );
} }
} }

View file

@ -1,12 +1,28 @@
'use client'; 'use client';
import { Plus, RefreshCw, Download, Upload, LayoutDashboard, Layers, List } from 'lucide-react'; import {
import { useState } from 'react'; Plus,
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; RefreshCw,
Download,
Upload,
LayoutDashboard,
Layers,
List,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal'; import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal';
import WidgetDisplay from '@/components/dashboard/WidgetDisplay'; import WidgetDisplay from '@/components/dashboard/WidgetDisplay';
import { useDashboard } from '@/lib/hooks/useDashboard'; import { useDashboard } from '@/lib/hooks/useDashboard';
import { Widget, WidgetConfig } from '@/lib/types'; import { Widget, WidgetConfig } from '@/lib/types';
import { toast } from 'sonner';
const DashboardPage = () => { const DashboardPage = () => {
const { const {
@ -24,7 +40,19 @@ const DashboardPage = () => {
} = useDashboard(); } = useDashboard();
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [editingWidget, setEditingWidget] = useState<Widget | null>(null); const handleAddWidget = () => { const [editingWidget, setEditingWidget] = useState<Widget | null>(null);
const hasAutoRefreshed = useRef(false);
// Auto-refresh stale widgets when dashboard loads (only once)
useEffect(() => {
if (!isLoading && widgets.length > 0 && !hasAutoRefreshed.current) {
hasAutoRefreshed.current = true;
refreshAllWidgets();
}
}, [isLoading, widgets, refreshAllWidgets]);
const handleAddWidget = () => {
setEditingWidget(null); setEditingWidget(null);
setShowAddModal(true); setShowAddModal(true);
}; };
@ -60,18 +88,18 @@ const DashboardPage = () => {
}; };
const handleRefreshAll = () => { const handleRefreshAll = () => {
refreshAllWidgets(); refreshAllWidgets(true);
}; };
const handleExport = async () => { const handleExport = async () => {
try { try {
const configJson = await exportDashboard(); const configJson = await exportDashboard();
await navigator.clipboard.writeText(configJson); await navigator.clipboard.writeText(configJson);
// TODO: Add toast notification for success toast.success('Dashboard configuration copied to clipboard');
console.log('Dashboard configuration copied to clipboard'); console.log('Dashboard configuration copied to clipboard');
} catch (error) { } catch (error) {
console.error('Export failed:', error); console.error('Export failed:', error);
// TODO: Add toast notification for error toast.error('Failed to copy dashboard configuration');
} }
}; };
@ -79,11 +107,11 @@ const DashboardPage = () => {
try { try {
const configJson = await navigator.clipboard.readText(); const configJson = await navigator.clipboard.readText();
await importDashboard(configJson); await importDashboard(configJson);
// TODO: Add toast notification for success toast.success('Dashboard configuration imported successfully');
console.log('Dashboard configuration imported successfully'); console.log('Dashboard configuration imported successfully');
} catch (error) { } catch (error) {
console.error('Import failed:', error); console.error('Import failed:', error);
// TODO: Add toast notification for error toast.error('Failed to import dashboard configuration');
} }
}; };
@ -98,13 +126,15 @@ const DashboardPage = () => {
<CardHeader> <CardHeader>
<CardTitle>Welcome to your Dashboard</CardTitle> <CardTitle>Welcome to your Dashboard</CardTitle>
<CardDescription> <CardDescription>
Create your first widget to get started with personalized information Create your first widget to get started with personalized
information
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Widgets let you fetch content from any URL and process it with AI to show exactly what you need. Widgets let you fetch content from any URL and process it with AI to
show exactly what you need.
</p> </p>
</CardContent> </CardContent>
@ -186,7 +216,9 @@ const DashboardPage = () => {
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Loading dashboard...</p> <p className="text-gray-500 dark:text-gray-400">
Loading dashboard...
</p>
</div> </div>
</div> </div>
) : widgets.length === 0 ? ( ) : widgets.length === 0 ? (

View file

@ -17,7 +17,7 @@ const extractThinkContent = (content: string): string | null => {
// Extract content between think tags and join if multiple // Extract content between think tags and join if multiple
const extractedContent = matches const extractedContent = matches
.map(match => match.replace(/<\/?think>/g, '')) .map((match) => match.replace(/<\/?think>/g, ''))
.join('\n\n'); .join('\n\n');
// Return null if content is empty or only whitespace // Return null if content is empty or only whitespace
@ -30,7 +30,7 @@ const removeThinkTags = (content: string): string => {
const ThinkTagProcessor = ({ const ThinkTagProcessor = ({
children, children,
isOverlayMode = false isOverlayMode = false,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
isOverlayMode?: boolean; isOverlayMode?: boolean;
@ -111,7 +111,11 @@ interface MarkdownRendererProps {
thinkOverlay?: boolean; thinkOverlay?: boolean;
} }
const MarkdownRenderer = ({ content, className, thinkOverlay = false }: MarkdownRendererProps) => { const MarkdownRenderer = ({
content,
className,
thinkOverlay = false,
}: MarkdownRendererProps) => {
const [showThinkBox, setShowThinkBox] = useState(false); const [showThinkBox, setShowThinkBox] = useState(false);
// Extract think content from the markdown // Extract think content from the markdown
@ -166,8 +170,8 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]', 'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', 'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0', 'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
'max-w-none break-words text-black dark:text-white', 'break-words text-black dark:text-white',
className className,
)} )}
options={markdownOverrides} options={markdownOverrides}
> >
@ -181,7 +185,10 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group" className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group"
title="Show thinking process" title="Show thinking process"
> >
<Brain size={16} className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" /> <Brain
size={16}
className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
/>
</button> </button>
)} )}
</div> </div>

View file

@ -273,10 +273,7 @@ const MessageTabs = ({
{/* Answer Tab */} {/* Answer Tab */}
{activeTab === 'text' && ( {activeTab === 'text' && (
<div className="flex flex-col space-y-4 animate-fadeIn"> <div className="flex flex-col space-y-4 animate-fadeIn">
<MarkdownRenderer <MarkdownRenderer content={parsedMessage} className="px-4" />
content={parsedMessage}
className="px-4"
/>
{loading && isLast ? null : ( {loading && isLast ? null : (
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4"> <div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4">

View file

@ -1,7 +1,14 @@
'use client'; 'use client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen, Settings, LayoutDashboard } from 'lucide-react'; import {
BookOpenText,
Home,
Search,
SquarePen,
Settings,
LayoutDashboard,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation'; import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useState, type ReactNode } from 'react'; import React, { useState, type ReactNode } from 'react';

View file

@ -20,7 +20,8 @@ const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
// Use external expanded state if provided, otherwise use internal state // Use external expanded state if provided, otherwise use internal state
const isExpanded = expanded !== undefined ? expanded : internalExpanded; const isExpanded = expanded !== undefined ? expanded : internalExpanded;
const handleToggle = onToggle || (() => setInternalExpanded(!internalExpanded)); const handleToggle =
onToggle || (() => setInternalExpanded(!internalExpanded));
return ( return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden"> <div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">

View file

@ -1,6 +1,12 @@
'use client'; 'use client';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { X, Plus, Trash2, Play, Save } from 'lucide-react'; import { X, Plus, Trash2, Play, Save } from 'lucide-react';
import { Fragment, useState, useEffect } from 'react'; import { Fragment, useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/MarkdownRenderer'; import MarkdownRenderer from '@/components/MarkdownRenderer';
@ -13,14 +19,22 @@ const replaceDateTimeVariables = (prompt: string): string => {
// Replace UTC datetime // Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) { if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString(); const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime); processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
} }
// Replace local datetime // Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) { if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date(); const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString(); const localDateTime = new Date(
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime); now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
} }
return processedPrompt; return processedPrompt;
@ -67,7 +81,10 @@ const WidgetConfigModal = ({
const [previewContent, setPreviewContent] = useState<string>(''); const [previewContent, setPreviewContent] = useState<string>('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<{ provider: string; model: string } | null>(null); const [selectedModel, setSelectedModel] = useState<{
provider: string;
model: string;
} | null>(null);
// Update config when editingWidget changes // Update config when editingWidget changes
useEffect(() => { useEffect(() => {
@ -106,7 +123,7 @@ const WidgetConfigModal = ({
// Update config when model selection changes // Update config when model selection changes
useEffect(() => { useEffect(() => {
if (selectedModel) { if (selectedModel) {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
provider: selectedModel.provider, provider: selectedModel.provider,
model: selectedModel.model, model: selectedModel.model,
@ -129,8 +146,13 @@ const WidgetConfigModal = ({
return; return;
} }
if (config.sources.length === 0 || config.sources.every(s => !s.url.trim())) { if (
setPreviewContent('Please add at least one source URL before running preview.'); config.sources.length === 0 ||
config.sources.every((s) => !s.url.trim())
) {
setPreviewContent(
'Please add at least one source URL before running preview.',
);
return; return;
} }
@ -145,7 +167,7 @@ const WidgetConfigModal = ({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
sources: config.sources.filter(s => s.url.trim()), // Only send sources with URLs sources: config.sources.filter((s) => s.url.trim()), // Only send sources with URLs
prompt: processedPrompt, prompt: processedPrompt,
provider: config.provider, provider: config.provider,
model: config.model, model: config.model,
@ -157,36 +179,40 @@ const WidgetConfigModal = ({
if (result.success) { if (result.success) {
setPreviewContent(result.content); setPreviewContent(result.content);
} else { } else {
setPreviewContent(`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`); setPreviewContent(
`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`,
);
} }
} catch (error) { } catch (error) {
console.error('Preview error:', error); console.error('Preview error:', error);
setPreviewContent(`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`); setPreviewContent(
`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`,
);
} finally { } finally {
setIsPreviewLoading(false); setIsPreviewLoading(false);
} }
}; };
const addSource = () => { const addSource = () => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: [...prev.sources, { url: '', type: 'Web Page' }] sources: [...prev.sources, { url: '', type: 'Web Page' }],
})); }));
}; };
const removeSource = (index: number) => { const removeSource = (index: number) => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: prev.sources.filter((_, i) => i !== index) sources: prev.sources.filter((_, i) => i !== index),
})); }));
}; };
const updateSource = (index: number, field: keyof Source, value: string) => { const updateSource = (index: number, field: keyof Source, value: string) => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: prev.sources.map((source, i) => sources: prev.sources.map((source, i) =>
i === index ? { ...source, [field]: value } : source i === index ? { ...source, [field]: value } : source,
) ),
})); }));
}; };
@ -241,7 +267,12 @@ const WidgetConfigModal = ({
<input <input
type="text" type="text"
value={config.title} value={config.title}
onChange={(e) => setConfig(prev => ({ ...prev, title: e.target.value }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
title: e.target.value,
}))
}
className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter widget title..." placeholder="Enter widget title..."
/> />
@ -258,13 +289,21 @@ const WidgetConfigModal = ({
<input <input
type="url" type="url"
value={source.url} value={source.url}
onChange={(e) => updateSource(index, 'url', e.target.value)} onChange={(e) =>
updateSource(index, 'url', e.target.value)
}
className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com" placeholder="https://example.com"
/> />
<select <select
value={source.type} value={source.type}
onChange={(e) => updateSource(index, 'type', e.target.value as Source['type'])} onChange={(e) =>
updateSource(
index,
'type',
e.target.value as Source['type'],
)
}
className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="Web Page">Web Page</option> <option value="Web Page">Web Page</option>
@ -297,7 +336,12 @@ const WidgetConfigModal = ({
</label> </label>
<textarea <textarea
value={config.prompt} value={config.prompt}
onChange={(e) => setConfig(prev => ({ ...prev, prompt: e.target.value }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
prompt: e.target.value,
}))
}
rows={6} rows={6}
className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter your prompt here..." placeholder="Enter your prompt here..."
@ -310,13 +354,14 @@ const WidgetConfigModal = ({
Model & Provider Model & Provider
</label> </label>
<ModelSelector <ModelSelector
selectedModel={selectedModel} selectedModel={selectedModel}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
truncateModelName={false} truncateModelName={false}
showModelName={true} showModelName={true}
/> />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Select the AI model and provider to process your widget content Select the AI model and provider to process your widget
content
</p> </p>
</div> </div>
@ -330,12 +375,24 @@ const WidgetConfigModal = ({
type="number" type="number"
min="1" min="1"
value={config.refreshFrequency} value={config.refreshFrequency}
onChange={(e) => setConfig(prev => ({ ...prev, refreshFrequency: parseInt(e.target.value) || 1 }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
refreshFrequency: parseInt(e.target.value) || 1,
}))
}
className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<select <select
value={config.refreshUnit} value={config.refreshUnit}
onChange={(e) => setConfig(prev => ({ ...prev, refreshUnit: e.target.value as 'minutes' | 'hours' }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
refreshUnit: e.target.value as
| 'minutes'
| 'hours',
}))
}
className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="minutes">Minutes</option> <option value="minutes">Minutes</option>
@ -361,10 +418,13 @@ const WidgetConfigModal = ({
</button> </button>
</div> </div>
<div className="h-80 p-4 border border-light-200 dark:border-dark-200 rounded-md bg-light-secondary dark:bg-dark-secondary overflow-y-auto"> <div className="h-80 p-4 border border-light-200 dark:border-dark-200 rounded-md bg-light-secondary dark:bg-dark-secondary overflow-y-auto max-w-full">
{previewContent ? ( {previewContent ? (
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-sm dark:prose-invert max-w-full">
<MarkdownRenderer thinkOverlay={true} content={previewContent} /> <MarkdownRenderer
thinkOverlay={true}
content={previewContent}
/>
</div> </div>
) : ( ) : (
<div className="text-sm text-black/50 dark:text-white/50 italic"> <div className="text-sm text-black/50 dark:text-white/50 italic">
@ -377,11 +437,36 @@ const WidgetConfigModal = ({
<div className="text-xs text-black/70 dark:text-white/70"> <div className="text-xs text-black/70 dark:text-white/70">
<h5 className="font-medium mb-2">Available Variables:</h5> <h5 className="font-medium mb-2">Available Variables:</h5>
<div className="space-y-1"> <div className="space-y-1">
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{current_utc_datetime}}'}</code> - Current UTC date and time</div> <div>
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{current_local_datetime}}'}</code> - Current local date and time</div> <code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{source_content_1}}'}</code> - Content from first source</div> {'{{current_utc_datetime}}'}
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{source_content_2}}'}</code> - Content from second source</div> </code>{' '}
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{location}}'}</code> - Your current location</div> - Current UTC date and time
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{current_local_datetime}}'}
</code>{' '}
- Current local date and time
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{source_content_1}}'}
</code>{' '}
- Content from first source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{source_content_2}}'}
</code>{' '}
- Content from second source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{location}}'}
</code>{' '}
- Your current location
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,14 @@
'use client'; 'use client';
import { RefreshCw, Edit, Trash2, Clock, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; import {
RefreshCw,
Edit,
Trash2,
Clock,
AlertCircle,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import MarkdownRenderer from '@/components/MarkdownRenderer'; import MarkdownRenderer from '@/components/MarkdownRenderer';
import { useState } from 'react'; import { useState } from 'react';
@ -32,7 +40,12 @@ interface WidgetDisplayProps {
onRefresh: (widgetId: string) => void; onRefresh: (widgetId: string) => void;
} }
const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayProps) => { const WidgetDisplay = ({
widget,
onEdit,
onDelete,
onRefresh,
}: WidgetDisplayProps) => {
const [isFooterExpanded, setIsFooterExpanded] = useState(false); const [isFooterExpanded, setIsFooterExpanded] = useState(false);
const formatLastUpdated = (date: Date | null) => { const formatLastUpdated = (date: Date | null) => {
@ -95,14 +108,21 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
</div> </div>
) : widget.error ? ( ) : widget.error ? (
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800"> <div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<AlertCircle size={16} className="text-red-500 mt-0.5 flex-shrink-0" /> <AlertCircle
size={16}
className="text-red-500 mt-0.5 flex-shrink-0"
/>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">Error Loading Content</p> <p className="text-sm font-medium text-red-800 dark:text-red-300">
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{widget.error}</p> Error Loading Content
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{widget.error}
</p>
</div> </div>
</div> </div>
) : widget.content ? ( ) : widget.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-sm dark:prose-invert">
<MarkdownRenderer content={widget.content} thinkOverlay={true} /> <MarkdownRenderer content={widget.content} thinkOverlay={true} />
</div> </div>
) : ( ) : (
@ -134,10 +154,15 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
{/* Sources */} {/* Sources */}
{widget.sources.length > 0 && ( {widget.sources.length > 0 && (
<div> <div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Sources:</p> <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Sources:
</p>
<div className="space-y-1"> <div className="space-y-1">
{widget.sources.map((source, index) => ( {widget.sources.map((source, index) => (
<div key={index} className="flex items-center space-x-2 text-xs"> <div
key={index}
className="flex items-center space-x-2 text-xs"
>
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full"></span> <span className="inline-block w-2 h-2 bg-blue-500 rounded-full"></span>
<span className="text-gray-600 dark:text-gray-300 truncate"> <span className="text-gray-600 dark:text-gray-300 truncate">
{source.url} {source.url}

View file

@ -21,7 +21,8 @@ interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode; children: React.ReactNode;
} }
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { interface CardDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode; children: React.ReactNode;
} }
@ -31,13 +32,13 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm', 'rounded-lg border bg-card text-card-foreground shadow-sm',
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) ),
); );
Card.displayName = 'Card'; Card.displayName = 'Card';
@ -50,7 +51,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
> >
{children} {children}
</div> </div>
) ),
); );
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
@ -60,27 +61,28 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, CardTitleProps>(
ref={ref} ref={ref}
className={cn( className={cn(
'text-2xl font-semibold leading-none tracking-tight', 'text-2xl font-semibold leading-none tracking-tight',
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</h3> </h3>
) ),
); );
CardTitle.displayName = 'CardTitle'; CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>( const CardDescription = React.forwardRef<
({ className, children, ...props }, ref) => ( HTMLParagraphElement,
<p CardDescriptionProps
ref={ref} >(({ className, children, ...props }, ref) => (
className={cn('text-sm text-muted-foreground', className)} <p
{...props} ref={ref}
> className={cn('text-sm text-muted-foreground', className)}
{children} {...props}
</p> >
) {children}
); </p>
));
CardDescription.displayName = 'CardDescription'; CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>( const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
@ -88,7 +90,7 @@ const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
<div ref={ref} className={cn('p-6 pt-0', className)} {...props}> <div ref={ref} className={cn('p-6 pt-0', className)} {...props}>
{children} {children}
</div> </div>
) ),
); );
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
@ -101,8 +103,15 @@ const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
> >
{children} {children}
</div> </div>
) ),
); );
CardFooter.displayName = 'CardFooter'; CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -5,7 +5,7 @@ import {
DashboardState, DashboardState,
DashboardConfig, DashboardConfig,
DASHBOARD_STORAGE_KEYS, DASHBOARD_STORAGE_KEYS,
WidgetCache WidgetCache,
} from '@/lib/types'; } from '@/lib/types';
// Helper function to request location permission and get user's location // Helper function to request location permission and get user's location
@ -31,7 +31,7 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
enableHighAccuracy: true, enableHighAccuracy: true,
timeout: 10000, // 10 seconds timeout timeout: 10000, // 10 seconds timeout
maximumAge: 300000, // 5 minutes cache maximumAge: 300000, // 5 minutes cache
} },
); );
}); });
} catch (error) { } catch (error) {
@ -47,14 +47,22 @@ const replaceDateTimeVariables = (prompt: string): string => {
// Replace UTC datetime // Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) { if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString(); const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime); processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
} }
// Replace local datetime // Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) { if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date(); const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString(); const localDateTime = new Date(
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime); now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
} }
return processedPrompt; return processedPrompt;
@ -72,7 +80,7 @@ interface UseDashboardReturn {
updateWidget: (id: string, config: WidgetConfig) => void; updateWidget: (id: string, config: WidgetConfig) => void;
deleteWidget: (id: string) => void; deleteWidget: (id: string) => void;
refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>; refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>;
refreshAllWidgets: () => Promise<void>; refreshAllWidgets: (forceRefresh?: boolean) => Promise<void>;
// Storage management // Storage management
exportDashboard: () => Promise<string>; exportDashboard: () => Promise<string>;
@ -103,13 +111,19 @@ export const useDashboard = (): UseDashboardReturn => {
// Save widgets to localStorage whenever they change (but not on initial load) // Save widgets to localStorage whenever they change (but not on initial load)
useEffect(() => { useEffect(() => {
if (!state.isLoading) { if (!state.isLoading) {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.WIDGETS, JSON.stringify(state.widgets)); localStorage.setItem(
DASHBOARD_STORAGE_KEYS.WIDGETS,
JSON.stringify(state.widgets),
);
} }
}, [state.widgets, state.isLoading]); }, [state.widgets, state.isLoading]);
// Save settings to localStorage whenever they change // Save settings to localStorage whenever they change
useEffect(() => { useEffect(() => {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.SETTINGS, JSON.stringify(state.settings)); localStorage.setItem(
DASHBOARD_STORAGE_KEYS.SETTINGS,
JSON.stringify(state.settings),
);
}, [state.settings]); }, [state.settings]);
const loadDashboardData = useCallback(() => { const loadDashboardData = useCallback(() => {
@ -119,21 +133,25 @@ export const useDashboard = (): UseDashboardReturn => {
const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : []; const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : [];
// Convert date strings back to Date objects // Convert date strings back to Date objects
widgets.forEach(widget => { widgets.forEach((widget) => {
if (widget.lastUpdated) { if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated); widget.lastUpdated = new Date(widget.lastUpdated);
} }
}); });
// Load settings // Load settings
const savedSettings = localStorage.getItem(DASHBOARD_STORAGE_KEYS.SETTINGS); const savedSettings = localStorage.getItem(
const settings = savedSettings ? JSON.parse(savedSettings) : { DASHBOARD_STORAGE_KEYS.SETTINGS,
parallelLoading: true, );
autoRefresh: false, const settings = savedSettings
theme: 'auto', ? JSON.parse(savedSettings)
}; : {
parallelLoading: true,
autoRefresh: false,
theme: 'auto',
};
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets, widgets,
settings, settings,
@ -141,7 +159,7 @@ export const useDashboard = (): UseDashboardReturn => {
})); }));
} catch (error) { } catch (error) {
console.error('Error loading dashboard data:', error); console.error('Error loading dashboard data:', error);
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
error: 'Failed to load dashboard data', error: 'Failed to load dashboard data',
isLoading: false, isLoading: false,
@ -159,27 +177,27 @@ export const useDashboard = (): UseDashboardReturn => {
error: null, error: null,
}; };
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: [...prev.widgets, newWidget], widgets: [...prev.widgets, newWidget],
})); }));
}, []); }, []);
const updateWidget = useCallback((id: string, config: WidgetConfig) => { const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: prev.widgets.map(widget => widgets: prev.widgets.map((widget) =>
widget.id === id widget.id === id
? { ...widget, ...config, id } // Preserve the ID ? { ...widget, ...config, id } // Preserve the ID
: widget : widget,
), ),
})); }));
}, []); }, []);
const deleteWidget = useCallback((id: string) => { const deleteWidget = useCallback((id: string) => {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: prev.widgets.filter(widget => widget.id !== id), widgets: prev.widgets.filter((widget) => widget.id !== id),
})); }));
// Also remove from cache // Also remove from cache
@ -211,132 +229,149 @@ export const useDashboard = (): UseDashboardReturn => {
const getCacheExpiryTime = (widget: Widget): Date => { const getCacheExpiryTime = (widget: Widget): Date => {
const now = new Date(); const now = new Date();
const refreshMs = widget.refreshFrequency * (widget.refreshUnit === 'hours' ? 3600000 : 60000); const refreshMs =
widget.refreshFrequency *
(widget.refreshUnit === 'hours' ? 3600000 : 60000);
return new Date(now.getTime() + refreshMs); return new Date(now.getTime() + refreshMs);
}; };
const refreshWidget = useCallback(async (id: string, forceRefresh: boolean = false) => { const refreshWidget = useCallback(
const widget = state.widgets.find(w => w.id === id); async (id: string, forceRefresh: boolean = false) => {
if (!widget) return; const widget = state.widgets.find((w) => w.id === id);
if (!widget) return;
// Check cache first (unless forcing refresh) // Check cache first (unless forcing refresh)
if (!forceRefresh && isWidgetCacheValid(widget)) { if (!forceRefresh && isWidgetCacheValid(widget)) {
const cache = getWidgetCache();
const cachedData = cache[widget.id];
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id
? { ...w, content: cachedData.content, lastUpdated: new Date(cachedData.lastFetched) }
: w
),
}));
return;
}
// Set loading state
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id ? { ...w, isLoading: true, error: null } : w
),
}));
try {
// Check if prompt uses location variable and request permission if needed
let location: string | undefined;
if (widget.prompt.includes('{{location}}')) {
location = await requestLocationPermission();
}
// Replace date/time variables on the client side
const processedPrompt = replaceDateTimeVariables(widget.prompt);
const response = await fetch('/api/dashboard/process-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: widget.sources,
prompt: processedPrompt,
provider: widget.provider,
model: widget.model,
location,
}),
});
const result = await response.json();
const now = new Date();
if (result.success) {
// Update widget
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id
? {
...w,
isLoading: false,
content: result.content,
lastUpdated: now,
error: null,
}
: w
),
}));
// Cache the result
const cache = getWidgetCache(); const cache = getWidgetCache();
cache[id] = { const cachedData = cache[widget.id];
content: result.content, setState((prev) => ({
lastFetched: now,
expiresAt: getCacheExpiryTime(widget),
};
localStorage.setItem(DASHBOARD_STORAGE_KEYS.CACHE, JSON.stringify(cache));
} else {
setState(prev => ({
...prev, ...prev,
widgets: prev.widgets.map(w => widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
content: cachedData.content,
lastUpdated: new Date(cachedData.lastFetched),
}
: w,
),
}));
return;
}
// Set loading state
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id ? { ...w, isLoading: true, error: null } : w,
),
}));
try {
// Check if prompt uses location variable and request permission if needed
let location: string | undefined;
if (widget.prompt.includes('{{location}}')) {
location = await requestLocationPermission();
}
// Replace date/time variables on the client side
const processedPrompt = replaceDateTimeVariables(widget.prompt);
const response = await fetch('/api/dashboard/process-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: widget.sources,
prompt: processedPrompt,
provider: widget.provider,
model: widget.model,
location,
}),
});
const result = await response.json();
const now = new Date();
if (result.success) {
// Update widget
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
content: result.content,
lastUpdated: now,
error: null,
}
: w,
),
}));
// Cache the result
const cache = getWidgetCache();
cache[id] = {
content: result.content,
lastFetched: now,
expiresAt: getCacheExpiryTime(widget),
};
localStorage.setItem(
DASHBOARD_STORAGE_KEYS.CACHE,
JSON.stringify(cache),
);
} else {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
error: result.error || 'Failed to refresh widget',
}
: w,
),
}));
}
} catch (error) {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id w.id === id
? { ? {
...w, ...w,
isLoading: false, isLoading: false,
error: result.error || 'Failed to refresh widget', error: 'Network error: Failed to refresh widget',
} }
: w : w,
), ),
})); }));
} }
} catch (error) { },
setState(prev => ({ [state.widgets],
...prev, );
widgets: prev.widgets.map(w =>
w.id === id
? {
...w,
isLoading: false,
error: 'Network error: Failed to refresh widget',
}
: w
),
}));
}
}, [state.widgets]);
const refreshAllWidgets = useCallback(async () => { const refreshAllWidgets = useCallback(
const activeWidgets = state.widgets.filter(w => !w.isLoading); async (forceRefresh = false) => {
const activeWidgets = state.widgets.filter((w) => !w.isLoading);
if (state.settings.parallelLoading) { if (state.settings.parallelLoading) {
// Refresh all widgets in parallel (force refresh) // Refresh all widgets in parallel (force refresh)
await Promise.all(activeWidgets.map(widget => refreshWidget(widget.id, true))); await Promise.all(
} else { activeWidgets.map((widget) => refreshWidget(widget.id, forceRefresh)),
// Refresh widgets sequentially (force refresh) );
for (const widget of activeWidgets) { } else {
await refreshWidget(widget.id, true); // Refresh widgets sequentially (force refresh)
for (const widget of activeWidgets) {
await refreshWidget(widget.id, forceRefresh);
}
} }
} },
}, [state.widgets, state.settings.parallelLoading, refreshWidget]); [state.widgets, state.settings.parallelLoading, refreshWidget],
);
const exportDashboard = useCallback(async (): Promise<string> => { const exportDashboard = useCallback(async (): Promise<string> => {
const dashboardConfig: DashboardConfig = { const dashboardConfig: DashboardConfig = {
@ -349,45 +384,57 @@ export const useDashboard = (): UseDashboardReturn => {
return JSON.stringify(dashboardConfig, null, 2); return JSON.stringify(dashboardConfig, null, 2);
}, [state.widgets, state.settings]); }, [state.widgets, state.settings]);
const importDashboard = useCallback(async (configJson: string): Promise<void> => { const importDashboard = useCallback(
try { async (configJson: string): Promise<void> => {
const config: DashboardConfig = JSON.parse(configJson); try {
const config: DashboardConfig = JSON.parse(configJson);
// Validate the config structure // Validate the config structure
if (!config.widgets || !Array.isArray(config.widgets)) { if (!config.widgets || !Array.isArray(config.widgets)) {
throw new Error('Invalid dashboard configuration: missing or invalid widgets array'); throw new Error(
'Invalid dashboard configuration: missing or invalid widgets array',
);
}
// Process widgets and ensure they have valid IDs
const processedWidgets: Widget[] = config.widgets.map((widget) => ({
...widget,
id:
widget.id ||
Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: widget.lastUpdated ? new Date(widget.lastUpdated) : null,
isLoading: false,
content: widget.content || null,
error: null,
}));
setState((prev) => ({
...prev,
widgets: processedWidgets,
settings: { ...prev.settings, ...config.settings },
}));
} catch (error) {
throw new Error(
`Failed to import dashboard: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
);
} }
},
// Process widgets and ensure they have valid IDs [],
const processedWidgets: Widget[] = config.widgets.map(widget => ({ );
...widget,
id: widget.id || Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: widget.lastUpdated ? new Date(widget.lastUpdated) : null,
isLoading: false,
content: widget.content || null,
error: null,
}));
setState(prev => ({
...prev,
widgets: processedWidgets,
settings: { ...prev.settings, ...config.settings },
}));
} catch (error) {
throw new Error(`Failed to import dashboard: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
}
}, []);
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE); localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE);
}, []); }, []);
const updateSettings = useCallback((newSettings: Partial<DashboardConfig['settings']>) => { const updateSettings = useCallback(
setState(prev => ({ (newSettings: Partial<DashboardConfig['settings']>) => {
...prev, setState((prev) => ({
settings: { ...prev.settings, ...newSettings }, ...prev,
})); settings: { ...prev.settings, ...newSettings },
}, []); }));
},
[],
);
return { return {
// State // State