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 { getWebContent, getWebContentLite } from '@/lib/utils/documents';
import { Document } from '@langchain/core/documents';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
@ -10,6 +11,7 @@ import {
getCustomOpenaiModelName,
} from '@/lib/config';
import { ChatOllama } from '@langchain/ollama';
import axios from 'axios';
interface Source {
url: string;
@ -24,7 +26,9 @@ interface WidgetProcessRequest {
}
// 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 {
let document;
@ -33,13 +37,17 @@ async function fetchSourceContent(source: Source): Promise<{ content: string; er
document = await getWebContent(source.url);
} else {
// 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) {
return {
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);
return {
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
function replacePromptVariables(prompt: string, sourceContents: string[], location?: string): string {
function replacePromptVariables(
prompt: string,
sourceContents: string[],
location?: string,
): string {
let processedPrompt = prompt;
// Replace source content variables
sourceContents.forEach((content, index) => {
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
@ -73,7 +87,10 @@ function replacePromptVariables(prompt: string, sourceContents: string[], locati
}
// 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 {
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
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);
if (!llm) {
@ -127,7 +148,7 @@ export async function POST(request: NextRequest) {
if (!body.sources || !body.prompt || !body.provider || !body.model) {
return NextResponse.json(
{ 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) {
return NextResponse.json(
{ error: 'At least one source URL is required' },
{ status: 400 }
{ status: 400 },
);
}
// Fetch content from all sources
console.log(`Processing widget with ${body.sources.length} source(s)`);
const sourceResults = await Promise.all(
body.sources.map(source => fetchSourceContent(source))
body.sources.map((source) => fetchSourceContent(source)),
);
// Check for fetch errors
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);
if (fetchErrors.length > 0) {
@ -155,31 +178,36 @@ export async function POST(request: NextRequest) {
}
// Extract successful content
const sourceContents = sourceResults.map(result => result.content);
const sourceContents = sourceResults.map((result) => result.content);
// If all sources failed, return error
if (sourceContents.every(content => !content)) {
if (sourceContents.every((content) => !content)) {
return NextResponse.json(
{ error: 'Failed to fetch content from all sources' },
{ status: 500 }
{ status: 500 },
);
}
// Replace variables in prompt
const processedPrompt = replacePromptVariables(body.prompt, sourceContents);
console.log('Processed prompt:', processedPrompt.substring(0, 200) + '...');
console.log('Processing prompt:', processedPrompt);
// Process with LLM
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({
content: llmResponse,
success: true,
sourcesFetched: sourceContents.filter(content => content).length,
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
warnings: fetchErrors.length > 0 ? fetchErrors : undefined
warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
});
} catch (llmError) {
console.error('LLM processing failed:', llmError);
@ -193,24 +221,26 @@ export async function POST(request: NextRequest) {
${processedPrompt}
## 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')}` : ''}`;
return NextResponse.json({
content: diagnosticResponse,
success: false,
error: llmError instanceof Error ? llmError.message : 'LLM processing failed',
sourcesFetched: sourceContents.filter(content => content).length,
totalSources: body.sources.length
error:
llmError instanceof Error
? llmError.message
: 'LLM processing failed',
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
});
}
} catch (error) {
console.error('Error processing widget:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
{ status: 500 },
);
}
}

View file

@ -1,12 +1,28 @@
'use client';
import { Plus, RefreshCw, Download, Upload, LayoutDashboard, Layers, List } from 'lucide-react';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import {
Plus,
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 WidgetDisplay from '@/components/dashboard/WidgetDisplay';
import { useDashboard } from '@/lib/hooks/useDashboard';
import { Widget, WidgetConfig } from '@/lib/types';
import { toast } from 'sonner';
const DashboardPage = () => {
const {
@ -24,7 +40,19 @@ const DashboardPage = () => {
} = useDashboard();
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);
setShowAddModal(true);
};
@ -60,18 +88,18 @@ const DashboardPage = () => {
};
const handleRefreshAll = () => {
refreshAllWidgets();
refreshAllWidgets(true);
};
const handleExport = async () => {
try {
const configJson = await exportDashboard();
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');
} catch (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 {
const configJson = await navigator.clipboard.readText();
await importDashboard(configJson);
// TODO: Add toast notification for success
toast.success('Dashboard configuration imported successfully');
console.log('Dashboard configuration imported successfully');
} catch (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>
<CardTitle>Welcome to your Dashboard</CardTitle>
<CardDescription>
Create your first widget to get started with personalized information
Create your first widget to get started with personalized
information
</CardDescription>
</CardHeader>
<CardContent>
<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>
</CardContent>
@ -186,7 +216,9 @@ const DashboardPage = () => {
<div className="flex items-center justify-center py-12">
<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>
<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>
) : widgets.length === 0 ? (

View file

@ -17,7 +17,7 @@ const extractThinkContent = (content: string): string | null => {
// Extract content between think tags and join if multiple
const extractedContent = matches
.map(match => match.replace(/<\/?think>/g, ''))
.map((match) => match.replace(/<\/?think>/g, ''))
.join('\n\n');
// Return null if content is empty or only whitespace
@ -30,7 +30,7 @@ const removeThinkTags = (content: string): string => {
const ThinkTagProcessor = ({
children,
isOverlayMode = false
isOverlayMode = false,
}: {
children: React.ReactNode;
isOverlayMode?: boolean;
@ -111,7 +111,11 @@ interface MarkdownRendererProps {
thinkOverlay?: boolean;
}
const MarkdownRenderer = ({ content, className, thinkOverlay = false }: MarkdownRendererProps) => {
const MarkdownRenderer = ({
content,
className,
thinkOverlay = false,
}: MarkdownRendererProps) => {
const [showThinkBox, setShowThinkBox] = useState(false);
// 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-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',
'max-w-none break-words text-black dark:text-white',
className
'break-words text-black dark:text-white',
className,
)}
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"
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>
)}
</div>

View file

@ -273,10 +273,7 @@ const MessageTabs = ({
{/* Answer Tab */}
{activeTab === 'text' && (
<div className="flex flex-col space-y-4 animate-fadeIn">
<MarkdownRenderer
content={parsedMessage}
className="px-4"
/>
<MarkdownRenderer content={parsedMessage} className="px-4" />
{loading && isLast ? null : (
<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';
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 { useSelectedLayoutSegments } from 'next/navigation';
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
const isExpanded = expanded !== undefined ? expanded : internalExpanded;
const handleToggle = onToggle || (() => setInternalExpanded(!internalExpanded));
const handleToggle =
onToggle || (() => setInternalExpanded(!internalExpanded));
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">

View file

@ -1,6 +1,12 @@
'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 { Fragment, useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
@ -13,14 +19,22 @@ const replaceDateTimeVariables = (prompt: string): string => {
// Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime);
processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
}
// Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime);
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
}
return processedPrompt;
@ -67,7 +81,10 @@ const WidgetConfigModal = ({
const [previewContent, setPreviewContent] = useState<string>('');
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
useEffect(() => {
@ -106,7 +123,7 @@ const WidgetConfigModal = ({
// Update config when model selection changes
useEffect(() => {
if (selectedModel) {
setConfig(prev => ({
setConfig((prev) => ({
...prev,
provider: selectedModel.provider,
model: selectedModel.model,
@ -129,8 +146,13 @@ const WidgetConfigModal = ({
return;
}
if (config.sources.length === 0 || config.sources.every(s => !s.url.trim())) {
setPreviewContent('Please add at least one source URL before running preview.');
if (
config.sources.length === 0 ||
config.sources.every((s) => !s.url.trim())
) {
setPreviewContent(
'Please add at least one source URL before running preview.',
);
return;
}
@ -145,7 +167,7 @@ const WidgetConfigModal = ({
'Content-Type': 'application/json',
},
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,
provider: config.provider,
model: config.model,
@ -157,36 +179,40 @@ const WidgetConfigModal = ({
if (result.success) {
setPreviewContent(result.content);
} 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) {
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 {
setIsPreviewLoading(false);
}
};
const addSource = () => {
setConfig(prev => ({
setConfig((prev) => ({
...prev,
sources: [...prev.sources, { url: '', type: 'Web Page' }]
sources: [...prev.sources, { url: '', type: 'Web Page' }],
}));
};
const removeSource = (index: number) => {
setConfig(prev => ({
setConfig((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) => {
setConfig(prev => ({
setConfig((prev) => ({
...prev,
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
type="text"
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"
placeholder="Enter widget title..."
/>
@ -258,13 +289,21 @@ const WidgetConfigModal = ({
<input
type="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"
placeholder="https://example.com"
/>
<select
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"
>
<option value="Web Page">Web Page</option>
@ -297,7 +336,12 @@ const WidgetConfigModal = ({
</label>
<textarea
value={config.prompt}
onChange={(e) => setConfig(prev => ({ ...prev, prompt: e.target.value }))}
onChange={(e) =>
setConfig((prev) => ({
...prev,
prompt: e.target.value,
}))
}
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"
placeholder="Enter your prompt here..."
@ -316,7 +360,8 @@ const WidgetConfigModal = ({
showModelName={true}
/>
<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>
</div>
@ -330,12 +375,24 @@ const WidgetConfigModal = ({
type="number"
min="1"
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"
/>
<select
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"
>
<option value="minutes">Minutes</option>
@ -361,10 +418,13 @@ const WidgetConfigModal = ({
</button>
</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 ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer thinkOverlay={true} content={previewContent} />
<div className="prose prose-sm dark:prose-invert max-w-full">
<MarkdownRenderer
thinkOverlay={true}
content={previewContent}
/>
</div>
) : (
<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">
<h5 className="font-medium mb-2">Available Variables:</h5>
<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><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>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{current_utc_datetime}}'}
</code>{' '}
- 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>

View file

@ -1,6 +1,14 @@
'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 MarkdownRenderer from '@/components/MarkdownRenderer';
import { useState } from 'react';
@ -32,7 +40,12 @@ interface WidgetDisplayProps {
onRefresh: (widgetId: string) => void;
}
const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayProps) => {
const WidgetDisplay = ({
widget,
onEdit,
onDelete,
onRefresh,
}: WidgetDisplayProps) => {
const [isFooterExpanded, setIsFooterExpanded] = useState(false);
const formatLastUpdated = (date: Date | null) => {
@ -95,14 +108,21 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
</div>
) : 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">
<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">
<p className="text-sm font-medium text-red-800 dark:text-red-300">Error Loading Content</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{widget.error}</p>
<p className="text-sm font-medium text-red-800 dark:text-red-300">
Error Loading Content
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{widget.error}
</p>
</div>
</div>
) : 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} />
</div>
) : (
@ -134,10 +154,15 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
{/* Sources */}
{widget.sources.length > 0 && (
<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">
{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="text-gray-600 dark:text-gray-300 truncate">
{source.url}

View file

@ -21,7 +21,8 @@ interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
}
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
interface CardDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
@ -31,13 +32,13 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
className,
)}
{...props}
>
{children}
</div>
)
),
);
Card.displayName = 'Card';
@ -50,7 +51,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
>
{children}
</div>
)
),
);
CardHeader.displayName = 'CardHeader';
@ -60,18 +61,20 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, CardTitleProps>(
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
className,
)}
{...props}
>
{children}
</h3>
)
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, children, ...props }, ref) => (
const CardDescription = React.forwardRef<
HTMLParagraphElement,
CardDescriptionProps
>(({ className, children, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
@ -79,8 +82,7 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionPr
>
{children}
</p>
)
);
));
CardDescription.displayName = 'CardDescription';
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}>
{children}
</div>
)
),
);
CardContent.displayName = 'CardContent';
@ -101,8 +103,15 @@ const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
>
{children}
</div>
)
),
);
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,
DashboardConfig,
DASHBOARD_STORAGE_KEYS,
WidgetCache
WidgetCache,
} from '@/lib/types';
// Helper function to request location permission and get user's location
@ -31,7 +31,7 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
enableHighAccuracy: true,
timeout: 10000, // 10 seconds timeout
maximumAge: 300000, // 5 minutes cache
}
},
);
});
} catch (error) {
@ -47,14 +47,22 @@ const replaceDateTimeVariables = (prompt: string): string => {
// Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime);
processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
}
// Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime);
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
}
return processedPrompt;
@ -72,7 +80,7 @@ interface UseDashboardReturn {
updateWidget: (id: string, config: WidgetConfig) => void;
deleteWidget: (id: string) => void;
refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>;
refreshAllWidgets: () => Promise<void>;
refreshAllWidgets: (forceRefresh?: boolean) => Promise<void>;
// Storage management
exportDashboard: () => Promise<string>;
@ -103,13 +111,19 @@ export const useDashboard = (): UseDashboardReturn => {
// Save widgets to localStorage whenever they change (but not on initial load)
useEffect(() => {
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]);
// Save settings to localStorage whenever they change
useEffect(() => {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.SETTINGS, JSON.stringify(state.settings));
localStorage.setItem(
DASHBOARD_STORAGE_KEYS.SETTINGS,
JSON.stringify(state.settings),
);
}, [state.settings]);
const loadDashboardData = useCallback(() => {
@ -119,21 +133,25 @@ export const useDashboard = (): UseDashboardReturn => {
const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : [];
// Convert date strings back to Date objects
widgets.forEach(widget => {
widgets.forEach((widget) => {
if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated);
}
});
// Load settings
const savedSettings = localStorage.getItem(DASHBOARD_STORAGE_KEYS.SETTINGS);
const settings = savedSettings ? JSON.parse(savedSettings) : {
const savedSettings = localStorage.getItem(
DASHBOARD_STORAGE_KEYS.SETTINGS,
);
const settings = savedSettings
? JSON.parse(savedSettings)
: {
parallelLoading: true,
autoRefresh: false,
theme: 'auto',
};
setState(prev => ({
setState((prev) => ({
...prev,
widgets,
settings,
@ -141,7 +159,7 @@ export const useDashboard = (): UseDashboardReturn => {
}));
} catch (error) {
console.error('Error loading dashboard data:', error);
setState(prev => ({
setState((prev) => ({
...prev,
error: 'Failed to load dashboard data',
isLoading: false,
@ -159,27 +177,27 @@ export const useDashboard = (): UseDashboardReturn => {
error: null,
};
setState(prev => ({
setState((prev) => ({
...prev,
widgets: [...prev.widgets, newWidget],
}));
}, []);
const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.map(widget =>
widgets: prev.widgets.map((widget) =>
widget.id === id
? { ...widget, ...config, id } // Preserve the ID
: widget
: widget,
),
}));
}, []);
const deleteWidget = useCallback((id: string) => {
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.filter(widget => widget.id !== id),
widgets: prev.widgets.filter((widget) => widget.id !== id),
}));
// Also remove from cache
@ -211,34 +229,41 @@ export const useDashboard = (): UseDashboardReturn => {
const getCacheExpiryTime = (widget: Widget): 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);
};
const refreshWidget = useCallback(async (id: string, forceRefresh: boolean = false) => {
const widget = state.widgets.find(w => w.id === id);
const refreshWidget = useCallback(
async (id: string, forceRefresh: boolean = false) => {
const widget = state.widgets.find((w) => w.id === id);
if (!widget) return;
// Check cache first (unless forcing refresh)
if (!forceRefresh && isWidgetCacheValid(widget)) {
const cache = getWidgetCache();
const cachedData = cache[widget.id];
setState(prev => ({
setState((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
? {
...w,
content: cachedData.content,
lastUpdated: new Date(cachedData.lastFetched),
}
: w,
),
}));
return;
}
// Set loading state
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id ? { ...w, isLoading: true, error: null } : w
widgets: prev.widgets.map((w) =>
w.id === id ? { ...w, isLoading: true, error: null } : w,
),
}));
@ -271,9 +296,9 @@ export const useDashboard = (): UseDashboardReturn => {
if (result.success) {
// Update widget
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.map(w =>
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
@ -282,7 +307,7 @@ export const useDashboard = (): UseDashboardReturn => {
lastUpdated: now,
error: null,
}
: w
: w,
),
}));
@ -293,50 +318,60 @@ export const useDashboard = (): UseDashboardReturn => {
lastFetched: now,
expiresAt: getCacheExpiryTime(widget),
};
localStorage.setItem(DASHBOARD_STORAGE_KEYS.CACHE, JSON.stringify(cache));
localStorage.setItem(
DASHBOARD_STORAGE_KEYS.CACHE,
JSON.stringify(cache),
);
} else {
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.map(w =>
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
error: result.error || 'Failed to refresh widget',
}
: w
: w,
),
}));
}
} catch (error) {
setState(prev => ({
setState((prev) => ({
...prev,
widgets: prev.widgets.map(w =>
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
error: 'Network error: Failed to refresh widget',
}
: w
: w,
),
}));
}
}, [state.widgets]);
},
[state.widgets],
);
const refreshAllWidgets = useCallback(async () => {
const activeWidgets = state.widgets.filter(w => !w.isLoading);
const refreshAllWidgets = useCallback(
async (forceRefresh = false) => {
const activeWidgets = state.widgets.filter((w) => !w.isLoading);
if (state.settings.parallelLoading) {
// Refresh all widgets in parallel (force refresh)
await Promise.all(activeWidgets.map(widget => refreshWidget(widget.id, true)));
await Promise.all(
activeWidgets.map((widget) => refreshWidget(widget.id, forceRefresh)),
);
} else {
// Refresh widgets sequentially (force refresh)
for (const widget of activeWidgets) {
await refreshWidget(widget.id, true);
await refreshWidget(widget.id, forceRefresh);
}
}
}, [state.widgets, state.settings.parallelLoading, refreshWidget]);
},
[state.widgets, state.settings.parallelLoading, refreshWidget],
);
const exportDashboard = useCallback(async (): Promise<string> => {
const dashboardConfig: DashboardConfig = {
@ -349,45 +384,57 @@ export const useDashboard = (): UseDashboardReturn => {
return JSON.stringify(dashboardConfig, null, 2);
}, [state.widgets, state.settings]);
const importDashboard = useCallback(async (configJson: string): Promise<void> => {
const importDashboard = useCallback(
async (configJson: string): Promise<void> => {
try {
const config: DashboardConfig = JSON.parse(configJson);
// Validate the config structure
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 => ({
const processedWidgets: Widget[] = config.widgets.map((widget) => ({
...widget,
id: widget.id || Date.now().toString() + Math.random().toString(36).substr(2, 9),
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 => ({
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'}`);
throw new Error(
`Failed to import dashboard: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
);
}
}, []);
},
[],
);
const clearCache = useCallback(() => {
localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE);
}, []);
const updateSettings = useCallback((newSettings: Partial<DashboardConfig['settings']>) => {
setState(prev => ({
const updateSettings = useCallback(
(newSettings: Partial<DashboardConfig['settings']>) => {
setState((prev) => ({
...prev,
settings: { ...prev.settings, ...newSettings },
}));
}, []);
},
[],
);
return {
// State