mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add tests and implementation for issue discovery and queue pages
- Implemented `DiscoveryPage` with session management and findings display. - Added tests for `DiscoveryPage` to ensure proper rendering and functionality. - Created `QueuePage` for managing issue execution queues with stats and actions. - Added tests for `QueuePage` to verify UI elements and translations. - Introduced `useIssues` hooks for fetching and managing issue data. - Added loading skeletons and error handling for better user experience. - Created `vite-env.d.ts` for TypeScript support in Vite environment.
This commit is contained in:
260
ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx
Normal file
260
ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
// ========================================
|
||||
// LogBlock Component
|
||||
// ========================================
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
Brain,
|
||||
Settings,
|
||||
Info,
|
||||
MessageCircle,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { LogBlockProps, LogLine } from './types';
|
||||
|
||||
// Re-use output line styling helpers from CliStreamMonitor
|
||||
function getOutputLineIcon(type: LogLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return <Brain className="h-3 w-3" />;
|
||||
case 'system':
|
||||
return <Settings className="h-3 w-3" />;
|
||||
case 'stderr':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'metadata':
|
||||
return <Info className="h-3 w-3" />;
|
||||
case 'tool_call':
|
||||
return <Wrench className="h-3 w-3" />;
|
||||
case 'stdout':
|
||||
default:
|
||||
return <MessageCircle className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputLineClass(type: LogLine['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockBorderClass(status: LogBlockProps['block']['status']): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'border-l-4 border-l-blue-500';
|
||||
case 'completed':
|
||||
return 'border-l-4 border-l-green-500';
|
||||
case 'error':
|
||||
return 'border-l-4 border-l-red-500';
|
||||
case 'pending':
|
||||
return 'border-l-4 border-l-yellow-500';
|
||||
default:
|
||||
return 'border-l-4 border-l-border';
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockTypeColor(type: LogBlockProps['block']['type']): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return 'text-blue-400';
|
||||
case 'tool':
|
||||
return 'text-green-400';
|
||||
case 'output':
|
||||
return 'text-foreground';
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
case 'warning':
|
||||
return 'text-yellow-400';
|
||||
case 'info':
|
||||
return 'text-cyan-400';
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeVariant(status: LogBlockProps['block']['status']): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'outline' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'info';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: LogBlockProps['block']['status']) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export const LogBlock = memo(function LogBlock({
|
||||
block,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onCopyCommand,
|
||||
onCopyOutput,
|
||||
onReRun,
|
||||
className,
|
||||
}: LogBlockProps) {
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', getBlockBorderClass(block.status), className)}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 bg-card cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
<div className="shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{getStatusIcon(block.status)}
|
||||
</div>
|
||||
|
||||
{/* Title with type-specific color */}
|
||||
<div className={cn('font-medium text-sm truncate', getBlockTypeColor(block.type))}>
|
||||
{block.title}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-1 min-w-0">
|
||||
{block.toolName && (
|
||||
<span className="truncate">{block.toolName}</span>
|
||||
)}
|
||||
<span className="shrink-0">{block.lineCount} lines</span>
|
||||
{block.duration !== undefined && (
|
||||
<span className="shrink-0">{formatDuration(block.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={getStatusBadgeVariant(block.status)} className="shrink-0">
|
||||
{block.status}
|
||||
</Badge>
|
||||
|
||||
{/* Action Buttons (visible on hover) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'shrink-0'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onCopyCommand}
|
||||
title="Copy command"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onCopyOutput}
|
||||
title="Copy output"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onReRun}
|
||||
title="Re-run"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 bg-background border-t border-border">
|
||||
<div className="font-mono text-xs space-y-1 max-h-96 overflow-y-auto">
|
||||
{block.lines.map((line, index) => (
|
||||
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all">{line.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance
|
||||
return (
|
||||
prevProps.block.id === nextProps.block.id &&
|
||||
prevProps.block.status === nextProps.block.status &&
|
||||
prevProps.block.lineCount === nextProps.block.lineCount &&
|
||||
prevProps.block.duration === nextProps.block.duration &&
|
||||
prevProps.isExpanded === nextProps.isExpanded &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
});
|
||||
|
||||
export default LogBlock;
|
||||
331
ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx
Normal file
331
ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
// ========================================
|
||||
// LogBlockList Component
|
||||
// ========================================
|
||||
// Container component for displaying grouped CLI output blocks
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { LogBlock } from './LogBlock';
|
||||
import type { LogBlockData, LogLine } from './types';
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
|
||||
/**
|
||||
* Parse tool call metadata from content
|
||||
* Expected format: "[Tool] toolName(args)"
|
||||
*/
|
||||
function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined {
|
||||
const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
|
||||
if (toolCallMatch) {
|
||||
return {
|
||||
toolName: toolCallMatch[1],
|
||||
args: toolCallMatch[2] || '',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate block title based on type and content
|
||||
*/
|
||||
function generateBlockTitle(lineType: string, content: string): string {
|
||||
switch (lineType) {
|
||||
case 'tool_call':
|
||||
const metadata = parseToolCallMetadata(content);
|
||||
if (metadata) {
|
||||
return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName;
|
||||
}
|
||||
return 'Tool Call';
|
||||
case 'thought':
|
||||
return 'Thought';
|
||||
case 'system':
|
||||
return 'System';
|
||||
case 'stderr':
|
||||
return 'Error Output';
|
||||
case 'stdout':
|
||||
return 'Output';
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
default:
|
||||
return 'Log';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block type for a line
|
||||
*/
|
||||
function getBlockType(lineType: string): LogBlockData['type'] {
|
||||
switch (lineType) {
|
||||
case 'tool_call':
|
||||
return 'tool';
|
||||
case 'thought':
|
||||
return 'info';
|
||||
case 'system':
|
||||
return 'info';
|
||||
case 'stderr':
|
||||
return 'error';
|
||||
case 'stdout':
|
||||
case 'metadata':
|
||||
default:
|
||||
return 'output';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line type should start a new block
|
||||
*/
|
||||
function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean {
|
||||
// No current block exists
|
||||
if (!currentBlockType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These types always start new blocks
|
||||
if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// stderr starts a new block if not already in stderr
|
||||
if (lineType === 'stderr' && currentBlockType !== 'stderr') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// tool_call block captures all following stdout/stderr until next tool_call
|
||||
if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// stderr block captures all stderr until next different type
|
||||
if (currentBlockType === 'stderr' && lineType === 'stderr') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// stdout merges into current stdout block
|
||||
if (currentBlockType === 'stdout' && lineType === 'stdout') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Different type - start new block
|
||||
if (currentBlockType !== lineType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group CLI output lines into log blocks
|
||||
*
|
||||
* Block grouping rules:
|
||||
* 1. tool_call starts new block, includes following stdout/stderr until next tool_call
|
||||
* 2. thought becomes independent block
|
||||
* 3. system becomes independent block
|
||||
* 4. stderr becomes highlighted block
|
||||
* 5. Other stdout merges into normal blocks
|
||||
*/
|
||||
function groupLinesIntoBlocks(
|
||||
lines: CliOutputLine[],
|
||||
executionId: string,
|
||||
executionStatus: 'running' | 'completed' | 'error'
|
||||
): LogBlockData[] {
|
||||
const blocks: LogBlockData[] = [];
|
||||
let currentLines: LogLine[] = [];
|
||||
let currentType: string | null = null;
|
||||
let currentTitle = '';
|
||||
let currentToolName: string | undefined;
|
||||
let blockStartTime = 0;
|
||||
let blockIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const blockType = getBlockType(line.type);
|
||||
|
||||
// Check if we need to start a new block
|
||||
if (shouldStartNewBlock(line.type, currentType)) {
|
||||
// Save current block if exists
|
||||
if (currentLines.length > 0) {
|
||||
const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined;
|
||||
blocks.push({
|
||||
id: `${executionId}-block-${blockIndex}`,
|
||||
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
|
||||
type: getBlockType(currentType || ''),
|
||||
status: executionStatus === 'running' ? 'running' : 'completed',
|
||||
toolName: currentToolName,
|
||||
lineCount: currentLines.length,
|
||||
duration,
|
||||
lines: currentLines,
|
||||
timestamp: blockStartTime,
|
||||
});
|
||||
blockIndex++;
|
||||
}
|
||||
|
||||
// Start new block
|
||||
currentType = line.type;
|
||||
currentTitle = generateBlockTitle(line.type, line.content);
|
||||
currentLines = [
|
||||
{
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp,
|
||||
},
|
||||
];
|
||||
blockStartTime = line.timestamp;
|
||||
|
||||
// Extract tool name for tool_call blocks
|
||||
if (line.type === 'tool_call') {
|
||||
const metadata = parseToolCallMetadata(line.content);
|
||||
currentToolName = metadata?.toolName;
|
||||
} else {
|
||||
currentToolName = undefined;
|
||||
}
|
||||
} else {
|
||||
// Add line to current block
|
||||
currentLines.push({
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the last block
|
||||
if (currentLines.length > 0) {
|
||||
const lastLine = currentLines[currentLines.length - 1];
|
||||
const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined;
|
||||
blocks.push({
|
||||
id: `${executionId}-block-${blockIndex}`,
|
||||
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
|
||||
type: getBlockType(currentType || ''),
|
||||
status: executionStatus === 'running' ? 'running' : 'completed',
|
||||
toolName: currentToolName,
|
||||
lineCount: currentLines.length,
|
||||
duration,
|
||||
lines: currentLines,
|
||||
timestamp: blockStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for LogBlockList component
|
||||
*/
|
||||
export interface LogBlockListProps {
|
||||
/** Execution ID to display logs for */
|
||||
executionId: string | null;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LogBlockList component
|
||||
* Displays CLI output grouped into collapsible blocks
|
||||
*/
|
||||
export function LogBlockList({ executionId, className }: LogBlockListProps) {
|
||||
// Get execution data from store
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
|
||||
// Get current execution or execution by ID
|
||||
const currentExecution = useMemo(() => {
|
||||
if (!executionId) return null;
|
||||
return executions[executionId] || null;
|
||||
}, [executions, executionId]);
|
||||
|
||||
// Manage expanded blocks state
|
||||
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Group output lines into blocks
|
||||
const blocks = useMemo(() => {
|
||||
if (!currentExecution?.output || currentExecution.output.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return groupLinesIntoBlocks(currentExecution.output, executionId!, currentExecution.status);
|
||||
}, [currentExecution, executionId]);
|
||||
|
||||
// Toggle block expand/collapse
|
||||
const toggleBlockExpand = useCallback((blockId: string) => {
|
||||
setExpandedBlocks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(blockId)) {
|
||||
next.delete(blockId);
|
||||
} else {
|
||||
next.add(blockId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Copy command to clipboard
|
||||
const copyCommand = useCallback((block: LogBlockData) => {
|
||||
const command = block.lines.find((l) => l.type === 'tool_call')?.content || '';
|
||||
navigator.clipboard.writeText(command).catch((err) => {
|
||||
console.error('Failed to copy command:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Copy output to clipboard
|
||||
const copyOutput = useCallback((block: LogBlockData) => {
|
||||
const output = block.lines.map((l) => l.content).join('\n');
|
||||
navigator.clipboard.writeText(output).catch((err) => {
|
||||
console.error('Failed to copy output:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-run block (placeholder for future implementation)
|
||||
const reRun = useCallback((block: LogBlockData) => {
|
||||
console.log('Re-run block:', block.id);
|
||||
// TODO: Implement re-run functionality
|
||||
}, []);
|
||||
|
||||
// Empty states
|
||||
if (!executionId) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No execution selected
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentExecution) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Execution not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
const isRunning = currentExecution.status === 'running';
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{isRunning ? 'Waiting for output...' : 'No output available'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2 p-3">
|
||||
{blocks.map((block) => (
|
||||
<LogBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
isExpanded={expandedBlocks.has(block.id)}
|
||||
onToggleExpand={() => toggleBlockExpand(block.id)}
|
||||
onCopyCommand={() => copyCommand(block)}
|
||||
onCopyOutput={() => copyOutput(block)}
|
||||
onReRun={() => reRun(block)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogBlockList;
|
||||
7
ccw/frontend/src/components/shared/LogBlock/index.ts
Normal file
7
ccw/frontend/src/components/shared/LogBlock/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ========================================
|
||||
// LogBlock Component Exports
|
||||
// ========================================
|
||||
|
||||
export { LogBlock, default } from './LogBlock';
|
||||
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
|
||||
export type { LogBlockProps, LogBlockData, LogLine } from './types';
|
||||
31
ccw/frontend/src/components/shared/LogBlock/types.ts
Normal file
31
ccw/frontend/src/components/shared/LogBlock/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// ========================================
|
||||
// LogBlock Types
|
||||
// ========================================
|
||||
|
||||
export interface LogBlockProps {
|
||||
block: LogBlockData;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCopyCommand: () => void;
|
||||
onCopyOutput: () => void;
|
||||
onReRun: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LogBlockData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info';
|
||||
status: 'running' | 'completed' | 'error' | 'pending';
|
||||
toolName?: string;
|
||||
lineCount: number;
|
||||
duration?: number;
|
||||
lines: LogLine[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
Reference in New Issue
Block a user