Merge pull request #341 from johndoe6345789/codex/create-dbal-and-irc-modules-and-components-mloc44

Modularize demo UIs for DBAL, IRC chat, and screenshot analyzer
This commit is contained in:
2025-12-28 04:11:49 +00:00
committed by GitHub
13 changed files with 565 additions and 408 deletions

View File

@@ -5,10 +5,15 @@
* with the MetaBuilder application.
*/
import { useMemo, useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { useDBAL } from '@/hooks/use-dbal/use-dbal'
import { BlobStorageDemo } from './dbal/BlobStorageDemo'
import { CachedDataDemo } from './dbal/CachedDataDemo'
import { ConnectionForm } from './dbal/ConnectionForm'
import { KVStoreDemo } from './dbal/KVStoreDemo'
import { LogsPanel } from './dbal/LogsPanel'
import { ResultPanel } from './dbal/ResultPanel'
import { DBALTabConfig, DBAL_CONTAINER_CLASS, DBAL_TAB_GRID_CLASS } from './dbal/dbal-demo.utils'
const tabs: DBALTabConfig[] = [
@@ -18,6 +23,25 @@ const tabs: DBALTabConfig[] = [
]
export function DBALDemo() {
const { isReady, error } = useDBAL()
const [logs, setLogs] = useState<string[]>([])
const [latestResult, setLatestResult] = useState<unknown>(null)
const statusMessage = useMemo(() => {
if (error) return `Error: ${error}`
return isReady ? 'Connected' : 'Initializing...'
}, [error, isReady])
const handleConnect = (config: { endpoint: string; apiKey: string }) => {
const timestamp = new Date().toLocaleTimeString()
setLogs((current) => [...current, `${timestamp}: Connected to ${config.endpoint}`])
setLatestResult({
endpoint: config.endpoint,
apiKey: config.apiKey ? '***' : 'Not provided',
ready: isReady,
})
}
return (
<div className={DBAL_CONTAINER_CLASS}>
<div className="mb-8">
@@ -27,6 +51,20 @@ export function DBALDemo() {
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 mb-6">
<ConnectionForm
defaultUrl={process.env.NEXT_PUBLIC_DBAL_ENDPOINT}
defaultApiKey={process.env.NEXT_PUBLIC_DBAL_API_KEY}
statusMessage={statusMessage}
onConnect={handleConnect}
/>
<ResultPanel title="Connection Details" result={latestResult} emptyLabel="Submit the form to log a connection" />
</div>
<div className="mb-6">
<LogsPanel logs={logs} title="Demo Logs" />
</div>
<Tabs defaultValue={tabs[0].value} className="space-y-4">
<TabsList className={DBAL_TAB_GRID_CLASS}>
{tabs.map((tab) => (

View File

@@ -1,21 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Badge } from '@/components/ui'
import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: 'message' | 'system' | 'join' | 'leave'
}
import { ChatWindow } from './irc/ChatWindow'
import { useChatInput, useFormattedTimes } from './irc/hooks'
import type { ChatMessage } from './irc/types'
interface IRCWebchatProps {
user: User
@@ -26,10 +14,9 @@ interface IRCWebchatProps {
export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebchatProps) {
const [messages, setMessages] = useKV<ChatMessage[]>(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV<string[]>(`chat_${channelName}_users`, [])
const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
const formattedTimes = useFormattedTimes(messages || [], formatTime)
useEffect(() => {
addUserToChannel()
@@ -38,14 +25,6 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}, [])
useEffect(() => {
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const addUserToChannel = () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -89,7 +68,7 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
})
}
const handleSendMessage = () => {
function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -151,121 +130,24 @@ export function IRCWebchat({ user, channelName = 'general', onClose }: IRCWebcha
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const formatTime = (timestamp: number) => {
function formatTime(timestamp: number) {
const date = new Date(timestamp)
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
}
const getMessageStyle = (msg: ChatMessage) => {
if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers?.length || 0}
</Badge>
<Button size="sm" variant="ghost" onClick={() => setShowSettings(!showSettings)}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-2 font-mono text-sm">
{(messages || []).map((msg) => (
<div key={msg.id} className={getMessageStyle(msg)}>
{msg.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{msg.username}&gt;</span>
<span className="break-words">{msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span>*** {msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className="text-accent">* {msg.username} {msg.message}</span>
</div>
)}
{(msg.type === 'join' || msg.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formatTime(msg.timestamp)}</span>
<span className={msg.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {msg.message}
</span>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<div className="space-y-1.5 text-sm">
{(onlineUsers || []).map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={handleSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Type /help for commands.
</p>
</div>
</CardContent>
</Card>
<ChatWindow
channelName={channelName}
messages={messages || []}
formattedTimes={formattedTimes}
onlineUsers={onlineUsers || []}
inputMessage={inputMessage}
onInputChange={setInputMessage}
onSendMessage={handleSendMessage}
onToggleSettings={() => setShowSettings(!showSettings)}
showSettings={showSettings}
onClose={onClose}
onInputKeyPress={handleKeyPress}
/>
)
}

View File

@@ -1,22 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Button } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Badge } from '@/components/ui'
import { PaperPlaneTilt, Users, SignOut, Gear } from '@phosphor-icons/react'
import { useState, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { User } from '@/lib/level-types'
import { getDeclarativeRenderer } from '@/lib/rendering-lib/declarative-component-renderer'
interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: 'message' | 'system' | 'join' | 'leave' | 'command'
}
import { ChatWindow } from './irc/ChatWindow'
import { useChatInput, useFormattedTimes } from './irc/hooks'
import type { ChatMessage } from './irc/types'
interface IRCWebchatDeclarativeProps {
user: User
@@ -27,11 +15,10 @@ interface IRCWebchatDeclarativeProps {
export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }: IRCWebchatDeclarativeProps) {
const [messages, setMessages] = useKV<ChatMessage[]>(`chat_${channelName}`, [])
const [onlineUsers, setOnlineUsers] = useKV<string[]>(`chat_${channelName}_users`, [])
const [inputMessage, setInputMessage] = useState('')
const [showSettings, setShowSettings] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { inputMessage, setInputMessage, handleKeyPress } = useChatInput(handleSendMessage)
const renderer = getDeclarativeRenderer()
const formattedTimes = useFormattedTimes(messages, formatTime)
useEffect(() => {
addUserToChannel()
@@ -40,14 +27,6 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}, [])
useEffect(() => {
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const addUserToChannel = async () => {
setOnlineUsers((current) => {
if (!current) return [user.username]
@@ -113,7 +92,7 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const handleSendMessage = async () => {
async function handleSendMessage() {
const trimmed = inputMessage.trim()
if (!trimmed) return
@@ -182,14 +161,9 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const formatTime = async (timestamp: number): Promise<string> => {
async function formatTime(timestamp: number): Promise<string> {
try {
const formatted = await renderer.executeLuaScript('lua_irc_format_time', [timestamp])
return formatted || new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
@@ -198,122 +172,19 @@ export function IRCWebchatDeclarative({ user, channelName = 'general', onClose }
}
}
const [formattedTimes, setFormattedTimes] = useState<Record<string, string>>({})
useEffect(() => {
const updateTimes = async () => {
const times: Record<string, string> = {}
for (const msg of messages || []) {
times[msg.id] = await formatTime(msg.timestamp)
}
setFormattedTimes(times)
}
updateTimes()
}, [messages])
const getMessageStyle = (msg: ChatMessage) => {
if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers?.length || 0}
</Badge>
<Button size="sm" variant="ghost" onClick={() => setShowSettings(!showSettings)}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-2 font-mono text-sm">
{(messages || []).map((msg) => (
<div key={msg.id} className={getMessageStyle(msg)}>
{msg.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{msg.username}&gt;</span>
<span className="break-words">{msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span>*** {msg.message}</span>
</div>
)}
{msg.type === 'system' && msg.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className="text-accent">* {msg.username} {msg.message}</span>
</div>
)}
{(msg.type === 'join' || msg.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[msg.id] || ''}</span>
<span className={msg.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {msg.message}
</span>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<div className="space-y-1.5 text-sm">
{(onlineUsers || []).map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={handleSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Type /help for commands.
</p>
</div>
</CardContent>
</Card>
<ChatWindow
channelName={channelName}
messages={messages || []}
formattedTimes={formattedTimes}
onlineUsers={onlineUsers || []}
inputMessage={inputMessage}
onInputChange={setInputMessage}
onSendMessage={handleSendMessage}
onToggleSettings={() => setShowSettings(!showSettings)}
showSettings={showSettings}
onClose={onClose}
onInputKeyPress={handleKeyPress}
/>
)
}

View File

@@ -1,26 +1,11 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardContent,
CardHeader,
Chip,
CircularProgress,
Stack,
Typography,
Grid,
} from '@mui/material'
import {
CameraAlt as CameraIcon,
Visibility as EyeIcon,
Download as DownloadIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import { Box, Card, CardContent, CardHeader, Chip, Grid, Typography } from '@mui/material'
import { toast } from 'sonner'
import { captureDomSnapshot } from '@/lib/screenshot/capture-dom-snapshot'
import { requestScreenshotAnalysis } from '@/lib/screenshot/request-screenshot-analysis'
import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
import { UploadSection } from './screenshot-analyzer/UploadSection'
import { ResultPanel } from './screenshot-analyzer/ResultPanel'
export function ScreenshotAnalyzer() {
const [isCapturing, setIsCapturing] = useState(false)
@@ -96,108 +81,16 @@ export function ScreenshotAnalyzer() {
<Chip label="Local Analysis" color="secondary" />
</Box>
<Card>
<CardHeader
title="Capture & Analyze"
subheader="Create a DOM snapshot and run heuristic checks"
/>
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
onClick={captureScreenshot}
disabled={isCapturing || isAnalyzing}
variant="contained"
startIcon={<CameraIcon />}
sx={{ flex: 1 }}
>
{isCapturing ? 'Capturing...' : 'Capture & Analyze'}
</Button>
<UploadSection
isCapturing={isCapturing}
isAnalyzing={isAnalyzing}
screenshotData={screenshotData}
onCapture={captureScreenshot}
onDownload={downloadScreenshot}
onReanalyze={analyzeScreenshot}
/>
{screenshotData && (
<>
<Button
onClick={downloadScreenshot}
variant="outlined"
startIcon={<DownloadIcon />}
>
Download
</Button>
<Button
onClick={analyzeScreenshot}
variant="outlined"
disabled={isAnalyzing}
startIcon={<RefreshIcon />}
>
Re-analyze
</Button>
</>
)}
</Box>
{isAnalyzing && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4, gap: 1.5 }}>
<CircularProgress size={24} />
<Typography color="text.secondary">Analyzing with heuristics...</Typography>
</Box>
)}
{analysisReport && !isAnalyzing && (
<Card variant="outlined" sx={{ bgcolor: 'action.hover' }}>
<CardHeader
avatar={<EyeIcon />}
title="Heuristic Analysis"
titleTypographyProps={{ variant: 'subtitle1' }}
/>
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{analysisResult && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip size="small" label={`Words: ${analysisResult.metrics.wordCount}`} />
<Chip size="small" label={`Headings: ${analysisResult.metrics.headingCount}`} />
<Chip size="small" label={`Links: ${analysisResult.metrics.linkCount}`} />
<Chip size="small" label={`Buttons: ${analysisResult.metrics.buttonCount}`} />
<Chip size="small" label={`Images: ${analysisResult.metrics.imgCount}`} />
<Chip size="small" label={`Missing alt: ${analysisResult.metrics.imgMissingAltCount}`} />
</Stack>
)}
{analysisResult?.warnings.length ? (
<Box>
<Typography variant="subtitle2" gutterBottom>Warnings</Typography>
<Box component="ul" sx={{ pl: 3, m: 0 }}>
{analysisResult.warnings.map((warning) => (
<li key={warning}>
<Typography variant="body2">{warning}</Typography>
</li>
))}
</Box>
</Box>
) : null}
<Typography
component="pre"
sx={{
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: '0.875rem',
}}
>
{analysisReport}
</Typography>
</CardContent>
</Card>
)}
{screenshotData && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" gutterBottom>Screenshot Preview</Typography>
<Box sx={{ maxHeight: 384, overflow: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Box component="img" src={screenshotData} alt="Page screenshot" sx={{ width: '100%' }} />
</Box>
</Box>
)}
</CardContent>
</Card>
<ResultPanel analysisReport={analysisReport} analysisResult={analysisResult} />
<Card>
<CardHeader title="Page Information" />

View File

@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
interface ConnectionFormProps {
defaultUrl?: string
defaultApiKey?: string
isConnecting?: boolean
statusMessage?: string
onConnect?: (config: { endpoint: string; apiKey: string }) => void
}
export function ConnectionForm({
defaultUrl = '',
defaultApiKey = '',
isConnecting = false,
statusMessage,
onConnect,
}: ConnectionFormProps) {
const [endpoint, setEndpoint] = useState(defaultUrl)
const [apiKey, setApiKey] = useState(defaultApiKey)
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
onConnect?.({ endpoint, apiKey })
}
return (
<Card>
<CardHeader>
<CardTitle>DBAL Connection</CardTitle>
<CardDescription>Configure the DBAL endpoint used by the demos</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="dbal-endpoint">Endpoint</Label>
<Input
id="dbal-endpoint"
placeholder="http://localhost:8080/api/dbal"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dbal-api-key">API Key</Label>
<Input
id="dbal-api-key"
type="password"
placeholder="Optional"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={isConnecting}>
{isConnecting ? 'Connecting…' : 'Connect'}
</Button>
{statusMessage ? <p className="text-sm text-muted-foreground">{statusMessage}</p> : null}
</div>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
interface LogsPanelProps {
logs: string[]
title?: string
}
export function LogsPanel({ logs, title = 'Activity' }: LogsPanelProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64 rounded border bg-muted/50 p-3 font-mono text-sm">
<div className="space-y-2">
{logs.length === 0 ? (
<p className="text-muted-foreground">No events yet</p>
) : (
logs.map((entry, index) => (
<div key={index} className="text-foreground">
{entry}
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
interface ResultPanelProps {
title?: string
result: unknown
emptyLabel?: string
}
export function ResultPanel({ title = 'Latest Result', result, emptyLabel = 'No result yet' }: ResultPanelProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{result ? (
<pre className="whitespace-pre-wrap break-words rounded bg-muted/50 p-3 text-sm">
{JSON.stringify(result, null, 2)}
</pre>
) : (
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,138 @@
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, ScrollArea } from '@/components/ui'
import { Gear, PaperPlaneTilt, SignOut, Users } from '@phosphor-icons/react'
import { UserList } from './UserList'
import type { ChatMessage } from './types'
interface ChatWindowProps {
channelName: string
messages: ChatMessage[]
formattedTimes: Record<string, string>
onlineUsers: string[]
inputMessage: string
onInputChange: (value: string) => void
onSendMessage: () => void
onToggleSettings: () => void
showSettings: boolean
onClose?: () => void
onInputKeyPress?: (event: React.KeyboardEvent) => void
}
export function ChatWindow({
channelName,
messages,
formattedTimes,
onlineUsers,
inputMessage,
onInputChange,
onSendMessage,
onToggleSettings,
showSettings,
onClose,
onInputKeyPress,
}: ChatWindowProps) {
const getMessageStyle = (message: ChatMessage) => {
if (message.type === 'system' || message.type === 'join' || message.type === 'leave' || message.type === 'command') {
return 'text-muted-foreground italic text-sm'
}
return ''
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<span className="font-mono">#</span>
{channelName}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<Users size={14} />
{onlineUsers.length}
</Badge>
<Button size="sm" variant="ghost" onClick={onToggleSettings}>
<Gear size={16} />
</Button>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose}>
<SignOut size={16} />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<ScrollArea className="flex-1 p-4">
<div className="space-y-2 font-mono text-sm">
{messages.map((message) => (
<div key={message.id} className={getMessageStyle(message)}>
{message.type === 'message' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="font-semibold shrink-0 text-primary">&lt;{message.username}&gt;</span>
<span className="break-words">{message.message}</span>
</div>
)}
{message.type === 'system' && message.username === 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span>*** {message.message}</span>
</div>
)}
{message.type === 'system' && message.username !== 'System' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="text-accent">* {message.username} {message.message}</span>
</div>
)}
{(message.type === 'join' || message.type === 'leave') && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className={message.type === 'join' ? 'text-green-500' : 'text-orange-500'}>
--&gt; {message.message}
</span>
</div>
)}
{message.type === 'command' && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">{formattedTimes[message.id] || ''}</span>
<span className="text-muted-foreground">{message.message}</span>
</div>
)}
</div>
))}
</div>
</ScrollArea>
{showSettings && (
<div className="w-48 border-l border-border p-4 bg-muted/20">
<h4 className="font-semibold text-sm mb-3">Online Users</h4>
<UserList users={onlineUsers} />
</div>
)}
</div>
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
value={inputMessage}
onChange={(event) => onInputChange(event.target.value)}
onKeyPress={onInputKeyPress}
placeholder="Type a message... (/help for commands)"
className="flex-1 font-mono"
/>
<Button onClick={onSendMessage} size="icon">
<PaperPlaneTilt size={18} />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">Press Enter to send. Type /help for commands.</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,20 @@
interface UserListProps {
users: string[]
}
export function UserList({ users }: UserListProps) {
if (users.length === 0) {
return <p className="text-sm text-muted-foreground">No users online</p>
}
return (
<div className="space-y-1.5 text-sm">
{users.map((username) => (
<div key={username} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span>{username}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import type { ChatMessage } from './types'
type TimestampFormatter = (timestamp: number) => Promise<string> | string
export function useChatInput(onSubmit: () => void) {
const [inputMessage, setInputMessage] = useState('')
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSubmit()
}
}
return {
inputMessage,
setInputMessage,
handleKeyPress,
}
}
export function useFormattedTimes(messages: ChatMessage[] | undefined, formatTime: TimestampFormatter) {
const [formattedTimes, setFormattedTimes] = useState<Record<string, string>>({})
useEffect(() => {
let isMounted = true
const formatAllTimes = async () => {
if (!messages) {
setFormattedTimes({})
return
}
const entries = await Promise.all(
messages.map(async (message) => {
const formatted = await formatTime(message.timestamp)
return [message.id, formatted] as const
}),
)
if (isMounted) {
setFormattedTimes(Object.fromEntries(entries))
}
}
formatAllTimes()
return () => {
isMounted = false
}
}, [messages, formatTime])
return formattedTimes
}

View File

@@ -0,0 +1,10 @@
export type ChatMessageType = 'message' | 'system' | 'join' | 'leave' | 'command'
export interface ChatMessage {
id: string
username: string
userId: string
message: string
timestamp: number
type: ChatMessageType
}

View File

@@ -0,0 +1,56 @@
import { Box, Card, CardContent, CardHeader, Chip, Stack, Typography } from '@mui/material'
import { Visibility as EyeIcon } from '@mui/icons-material'
import type { ScreenshotAnalysisResult } from '@/lib/screenshot/types'
interface ResultPanelProps {
analysisReport: string
analysisResult: ScreenshotAnalysisResult | null
}
export function ResultPanel({ analysisReport, analysisResult }: ResultPanelProps) {
if (!analysisReport) return null
return (
<Card variant="outlined" sx={{ bgcolor: 'action.hover' }}>
<CardHeader avatar={<EyeIcon />} title="Heuristic Analysis" titleTypographyProps={{ variant: 'subtitle1' }} />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{analysisResult && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip size="small" label={`Words: ${analysisResult.metrics.wordCount}`} />
<Chip size="small" label={`Headings: ${analysisResult.metrics.headingCount}`} />
<Chip size="small" label={`Links: ${analysisResult.metrics.linkCount}`} />
<Chip size="small" label={`Buttons: ${analysisResult.metrics.buttonCount}`} />
<Chip size="small" label={`Images: ${analysisResult.metrics.imgCount}`} />
<Chip size="small" label={`Missing alt: ${analysisResult.metrics.imgMissingAltCount}`} />
</Stack>
)}
{analysisResult?.warnings.length ? (
<Box>
<Typography variant="subtitle2" gutterBottom>
Warnings
</Typography>
<Box component="ul" sx={{ pl: 3, m: 0 }}>
{analysisResult.warnings.map((warning) => (
<li key={warning}>
<Typography variant="body2">{warning}</Typography>
</li>
))}
</Box>
</Box>
) : null}
<Typography
component="pre"
sx={{
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: '0.875rem',
}}
>
{analysisReport}
</Typography>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,71 @@
import { Box, Button, Card, CardContent, CardHeader, CircularProgress, Typography } from '@mui/material'
import { CameraAlt as CameraIcon, Download as DownloadIcon, Refresh as RefreshIcon } from '@mui/icons-material'
interface UploadSectionProps {
isCapturing: boolean
isAnalyzing: boolean
screenshotData: string | null
onCapture: () => void
onDownload: () => void
onReanalyze: () => void
previewTitle?: string
}
export function UploadSection({
isCapturing,
isAnalyzing,
screenshotData,
onCapture,
onDownload,
onReanalyze,
previewTitle = 'Screenshot Preview',
}: UploadSectionProps) {
return (
<Card>
<CardHeader title="Capture & Analyze" subheader="Create a DOM snapshot and run heuristic checks" />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
onClick={onCapture}
disabled={isCapturing || isAnalyzing}
variant="contained"
startIcon={<CameraIcon />}
sx={{ flex: 1 }}
>
{isCapturing ? 'Capturing...' : 'Capture & Analyze'}
</Button>
{screenshotData && (
<>
<Button onClick={onDownload} variant="outlined" startIcon={<DownloadIcon />}>
Download
</Button>
<Button onClick={onReanalyze} variant="outlined" disabled={isAnalyzing} startIcon={<RefreshIcon />}>
Re-analyze
</Button>
</>
)}
</Box>
{isAnalyzing && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4, gap: 1.5 }}>
<CircularProgress size={24} />
<Typography color="text.secondary">Analyzing with heuristics...</Typography>
</Box>
)}
{screenshotData && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" gutterBottom>
{previewTitle}
</Typography>
<Box sx={{ maxHeight: 384, overflow: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Box component="img" src={screenshotData} alt="Page screenshot" sx={{ width: '100%' }} />
</Box>
</Box>
)}
</CardContent>
</Card>
)
}