mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-27 07:14:56 +00:00
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:
@@ -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) => (
|
||||
|
||||
@@ -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"><{msg.username}></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'}>
|
||||
--> {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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"><{msg.username}></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'}>
|
||||
--> {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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
138
frontends/nextjs/src/components/misc/demos/irc/ChatWindow.tsx
Normal file
138
frontends/nextjs/src/components/misc/demos/irc/ChatWindow.tsx
Normal 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"><{message.username}></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'}>
|
||||
--> {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>
|
||||
)
|
||||
}
|
||||
20
frontends/nextjs/src/components/misc/demos/irc/UserList.tsx
Normal file
20
frontends/nextjs/src/components/misc/demos/irc/UserList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontends/nextjs/src/components/misc/demos/irc/hooks.ts
Normal file
55
frontends/nextjs/src/components/misc/demos/irc/hooks.ts
Normal 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
|
||||
}
|
||||
10
frontends/nextjs/src/components/misc/demos/irc/types.ts
Normal file
10
frontends/nextjs/src/components/misc/demos/irc/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user