mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: implement ideation feature for brainstorming and idea management
- Introduced a new IdeationService to manage brainstorming sessions, including idea creation, analysis, and conversion to features. - Added RESTful API routes for ideation, including session management, idea CRUD operations, and suggestion generation. - Created UI components for the ideation dashboard, prompt selection, and category grid to enhance user experience. - Integrated keyboard shortcuts and navigation for the ideation feature, improving accessibility and workflow. - Updated state management with Zustand to handle ideation-specific data and actions. - Added necessary types and paths for ideation functionality, ensuring type safety and clarity in the codebase.
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* IdeationDashboard - Main dashboard showing all generated suggestions
|
||||
* First page users see - shows all ideas ready for accept/reject
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AnalysisSuggestion } from '@automaker/types';
|
||||
|
||||
interface IdeationDashboardProps {
|
||||
onGenerateIdeas: () => void;
|
||||
}
|
||||
|
||||
function SuggestionCard({
|
||||
suggestion,
|
||||
job,
|
||||
onAccept,
|
||||
onRemove,
|
||||
isAdding,
|
||||
}: {
|
||||
suggestion: AnalysisSuggestion;
|
||||
job: GenerationJob;
|
||||
onAccept: () => void;
|
||||
onRemove: () => void;
|
||||
isAdding: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="transition-all hover:border-primary/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{suggestion.title}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{suggestion.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{job.prompt.title}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
||||
{suggestion.rationale && (
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">{suggestion.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
disabled={isAdding}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={onAccept} disabled={isAdding} className="gap-1">
|
||||
{isAdding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Accept
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratingCard({ job }: { job: GenerationJob }) {
|
||||
const { removeJob } = useIdeationStore();
|
||||
const isError = job.status === 'error';
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all', isError ? 'border-red-500/50' : 'border-blue-500/50')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isError ? (
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{job.prompt.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeJob(job.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFilter({
|
||||
tags,
|
||||
tagCounts,
|
||||
selectedTags,
|
||||
onToggleTag,
|
||||
}: {
|
||||
tags: string[];
|
||||
tagCounts: Record<string, number>;
|
||||
selectedTags: Set<string>;
|
||||
onToggleTag: (tag: string) => void;
|
||||
}) {
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.has(tag);
|
||||
const count = tagCounts[tag] || 0;
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onToggleTag(tag)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-full border transition-all flex items-center gap-1.5',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-secondary/50 text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
({count})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedTags.size > 0 && (
|
||||
<button
|
||||
onClick={() => selectedTags.forEach((tag) => onToggleTag(tag))}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const { generationJobs, removeSuggestionFromJob } = useIdeationStore();
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||
|
||||
// Separate generating/error jobs from ready jobs with suggestions
|
||||
const activeJobs = generationJobs.filter(
|
||||
(j) => j.status === 'generating' || j.status === 'error'
|
||||
);
|
||||
const readyJobs = generationJobs.filter((j) => j.status === 'ready' && j.suggestions.length > 0);
|
||||
|
||||
// Flatten all suggestions with their parent job
|
||||
const allSuggestions = useMemo(
|
||||
() => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))),
|
||||
[readyJobs]
|
||||
);
|
||||
|
||||
// Extract unique tags and counts from all suggestions
|
||||
const { availableTags, tagCounts } = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
allSuggestions.forEach(({ job }) => {
|
||||
const tag = job.prompt.title;
|
||||
counts[tag] = (counts[tag] || 0) + 1;
|
||||
});
|
||||
return {
|
||||
availableTags: Object.keys(counts).sort(),
|
||||
tagCounts: counts,
|
||||
};
|
||||
}, [allSuggestions]);
|
||||
|
||||
// Filter suggestions based on selected tags
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (selectedTags.size === 0) return allSuggestions;
|
||||
return allSuggestions.filter(({ job }) => selectedTags.has(job.prompt.title));
|
||||
}, [allSuggestions, selectedTags]);
|
||||
|
||||
const generatingCount = generationJobs.filter((j) => j.status === 'generating').length;
|
||||
|
||||
const handleToggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tag)) {
|
||||
next.delete(tag);
|
||||
} else {
|
||||
next.add(tag);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccept = async (suggestion: AnalysisSuggestion, jobId: string) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingId(suggestion.id);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
|
||||
|
||||
if (result?.success) {
|
||||
toast.success(`Added "${suggestion.title}" to board`);
|
||||
removeSuggestionFromJob(jobId, suggestion.id);
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to add to board');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add to board:', error);
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setAddingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (suggestionId: string, jobId: string) => {
|
||||
removeSuggestionFromJob(jobId, suggestionId);
|
||||
toast.info('Idea removed');
|
||||
};
|
||||
|
||||
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-6 overflow-auto">
|
||||
<div className="max-w-3xl w-full mx-auto space-y-4">
|
||||
{/* Status text */}
|
||||
{(generatingCount > 0 || allSuggestions.length > 0) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{generatingCount > 0
|
||||
? `Generating ${generatingCount} idea${generatingCount > 1 ? 's' : ''}...`
|
||||
: selectedTags.size > 0
|
||||
? `Showing ${filteredSuggestions.length} of ${allSuggestions.length} ideas`
|
||||
: `${allSuggestions.length} idea${allSuggestions.length > 1 ? 's' : ''} ready for review`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tag Filters */}
|
||||
{availableTags.length > 0 && (
|
||||
<TagFilter
|
||||
tags={availableTags}
|
||||
tagCounts={tagCounts}
|
||||
selectedTags={selectedTags}
|
||||
onToggleTag={handleToggleTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Generating/Error Jobs */}
|
||||
{activeJobs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{activeJobs.map((job) => (
|
||||
<GeneratingCard key={job.id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions List */}
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredSuggestions.map(({ suggestion, job }) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
job={job}
|
||||
onAccept={() => handleAccept(suggestion, job.id)}
|
||||
onRemove={() => handleRemove(suggestion.id, job.id)}
|
||||
isAdding={addingId === suggestion.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results after filtering */}
|
||||
{filteredSuggestions.length === 0 && allSuggestions.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>No ideas match the selected filters</p>
|
||||
<button
|
||||
onClick={() => setSelectedTags(new Set())}
|
||||
className="text-primary hover:underline mt-2"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{isEmpty && (
|
||||
<Card>
|
||||
<CardContent className="py-16">
|
||||
<div className="text-center">
|
||||
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No ideas yet</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Generate ideas by selecting a category and prompt type
|
||||
</p>
|
||||
<Button onClick={onGenerateIdeas} size="lg" className="gap-2">
|
||||
<Lightbulb className="w-5 h-5" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PromptCategoryGrid - Grid of prompt categories to select from
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Zap,
|
||||
Palette,
|
||||
Code,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
Shield,
|
||||
Gauge,
|
||||
Accessibility,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PROMPT_CATEGORIES } from '../data/guided-prompts';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
|
||||
interface PromptCategoryGridProps {
|
||||
onSelect: (category: IdeaCategory) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, typeof Zap> = {
|
||||
Zap,
|
||||
Palette,
|
||||
Code,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
Shield,
|
||||
Gauge,
|
||||
Accessibility,
|
||||
BarChart3,
|
||||
};
|
||||
|
||||
export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-6 overflow-auto">
|
||||
<div className="max-w-4xl w-full mx-auto space-y-4">
|
||||
{/* Back link */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{PROMPT_CATEGORIES.map((category) => {
|
||||
const Icon = iconMap[category.icon] || Zap;
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Icon className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{category.name}</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* PromptList - List of prompts for a specific category
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getPromptsByCategory } from '../data/guided-prompts';
|
||||
import { useIdeationStore } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
||||
|
||||
interface PromptListProps {
|
||||
category: IdeaCategory;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function PromptList({ category, onBack }: PromptListProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const { setMode, addGenerationJob, updateJobStatus, generationJobs } = useIdeationStore();
|
||||
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
||||
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const prompts = getPromptsByCategory(category);
|
||||
|
||||
// Check which prompts are already generating
|
||||
const generatingPromptIds = new Set(
|
||||
generationJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)
|
||||
);
|
||||
|
||||
const handleSelectPrompt = async (prompt: IdeationPrompt) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
|
||||
|
||||
setLoadingPromptId(prompt.id);
|
||||
|
||||
// Add a job and navigate to dashboard
|
||||
const jobId = addGenerationJob(prompt);
|
||||
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
|
||||
|
||||
// Show toast and navigate to dashboard
|
||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||
setMode('dashboard');
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ideation?.generateSuggestions(
|
||||
currentProject.path,
|
||||
prompt.id,
|
||||
category
|
||||
);
|
||||
|
||||
if (result?.success && result.suggestions) {
|
||||
updateJobStatus(jobId, 'ready', result.suggestions);
|
||||
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'View Ideas',
|
||||
onClick: () => {
|
||||
setMode('dashboard');
|
||||
navigate({ to: '/ideation' });
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'error',
|
||||
undefined,
|
||||
result?.error || 'Failed to generate suggestions'
|
||||
);
|
||||
toast.error(result?.error || 'Failed to generate suggestions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate suggestions:', error);
|
||||
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setLoadingPromptId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-6 overflow-auto">
|
||||
<div className="max-w-3xl w-full mx-auto space-y-4">
|
||||
{/* Back link */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
{prompts.map((prompt) => {
|
||||
const isLoading = loadingPromptId === prompt.id;
|
||||
const isGenerating = generatingPromptIds.has(prompt.id);
|
||||
const isStarted = startedPrompts.has(prompt.id);
|
||||
const isDisabled = loadingPromptId !== null || isGenerating;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={prompt.id}
|
||||
className={`transition-all ${
|
||||
isDisabled
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-primary hover:shadow-md'
|
||||
} ${isLoading || isGenerating ? 'border-blue-500 ring-1 ring-blue-500' : ''} ${
|
||||
isStarted && !isGenerating ? 'border-green-500/50' : ''
|
||||
}`}
|
||||
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`p-2 rounded-lg mt-0.5 ${
|
||||
isLoading || isGenerating
|
||||
? 'bg-blue-500/10'
|
||||
: isStarted
|
||||
? 'bg-green-500/10'
|
||||
: 'bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{isLoading || isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
) : isStarted ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Lightbulb className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold">{prompt.title}</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">{prompt.description}</p>
|
||||
{(isLoading || isGenerating) && (
|
||||
<p className="text-blue-500 text-sm mt-2">Generating in dashboard...</p>
|
||||
)}
|
||||
{isStarted && !isGenerating && (
|
||||
<p className="text-green-500 text-sm mt-2">
|
||||
Already generated - check dashboard
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Guided prompts for ideation sessions
|
||||
* Static data that provides pre-made prompts for different categories
|
||||
*/
|
||||
|
||||
import type { IdeaCategory, IdeationPrompt, PromptCategory } from '@automaker/types';
|
||||
|
||||
export const PROMPT_CATEGORIES: PromptCategory[] = [
|
||||
{
|
||||
id: 'feature',
|
||||
name: 'Features',
|
||||
icon: 'Zap',
|
||||
description: 'New capabilities and functionality',
|
||||
},
|
||||
{
|
||||
id: 'ux-ui',
|
||||
name: 'UX/UI',
|
||||
icon: 'Palette',
|
||||
description: 'Design and user experience improvements',
|
||||
},
|
||||
{
|
||||
id: 'dx',
|
||||
name: 'Developer Experience',
|
||||
icon: 'Code',
|
||||
description: 'Developer tooling and workflows',
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
name: 'Growth',
|
||||
icon: 'TrendingUp',
|
||||
description: 'User engagement and retention',
|
||||
},
|
||||
{
|
||||
id: 'technical',
|
||||
name: 'Technical',
|
||||
icon: 'Cpu',
|
||||
description: 'Architecture and infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
name: 'Security',
|
||||
icon: 'Shield',
|
||||
description: 'Security and privacy improvements',
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
name: 'Performance',
|
||||
icon: 'Gauge',
|
||||
description: 'Speed and optimization',
|
||||
},
|
||||
{
|
||||
id: 'accessibility',
|
||||
name: 'Accessibility',
|
||||
icon: 'Accessibility',
|
||||
description: 'Inclusive design for all users',
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
icon: 'BarChart3',
|
||||
description: 'Data insights and tracking',
|
||||
},
|
||||
];
|
||||
|
||||
export const GUIDED_PROMPTS: IdeationPrompt[] = [
|
||||
// Feature prompts
|
||||
{
|
||||
id: 'feature-missing',
|
||||
category: 'feature',
|
||||
title: 'Missing Features',
|
||||
description: 'Discover features users might expect',
|
||||
prompt:
|
||||
"Analyze this codebase and identify features that users of similar applications typically expect but are missing here. Consider the app's domain, target users, and common patterns in similar products.",
|
||||
},
|
||||
{
|
||||
id: 'feature-automation',
|
||||
category: 'feature',
|
||||
title: 'Automation Opportunities',
|
||||
description: 'Find manual processes that could be automated',
|
||||
prompt:
|
||||
'Review this codebase and identify manual processes or repetitive tasks that could be automated. Look for patterns where users might be doing things repeatedly that software could handle.',
|
||||
},
|
||||
{
|
||||
id: 'feature-integrations',
|
||||
category: 'feature',
|
||||
title: 'Integration Ideas',
|
||||
description: 'Identify valuable third-party integrations',
|
||||
prompt:
|
||||
"Based on this codebase, what third-party services or APIs would provide value if integrated? Consider the app's domain and what complementary services users might need.",
|
||||
},
|
||||
{
|
||||
id: 'feature-workflow',
|
||||
category: 'feature',
|
||||
title: 'Workflow Improvements',
|
||||
description: 'Streamline user workflows',
|
||||
prompt:
|
||||
'Analyze the user workflows in this application. What steps could be combined, eliminated, or automated? Where are users likely spending too much time on repetitive tasks?',
|
||||
},
|
||||
|
||||
// UX/UI prompts
|
||||
{
|
||||
id: 'ux-friction',
|
||||
category: 'ux-ui',
|
||||
title: 'Friction Points',
|
||||
description: 'Identify where users might get stuck',
|
||||
prompt:
|
||||
'Analyze the user flows in this codebase and identify potential friction points. Where might users get confused, stuck, or frustrated? Look at form submissions, navigation, error states, and complex interactions.',
|
||||
},
|
||||
{
|
||||
id: 'ux-empty-states',
|
||||
category: 'ux-ui',
|
||||
title: 'Empty States',
|
||||
description: 'Improve empty state experiences',
|
||||
prompt:
|
||||
"Review the components in this codebase and identify empty states that could be improved. How can we guide users when there's no content? Consider onboarding, helpful prompts, and sample data.",
|
||||
},
|
||||
{
|
||||
id: 'ux-accessibility',
|
||||
category: 'ux-ui',
|
||||
title: 'Accessibility Improvements',
|
||||
description: 'Enhance accessibility and inclusivity',
|
||||
prompt:
|
||||
'Analyze this codebase for accessibility improvements. Consider keyboard navigation, screen reader support, color contrast, focus states, and ARIA labels. What specific improvements would make this more accessible?',
|
||||
},
|
||||
{
|
||||
id: 'ux-mobile',
|
||||
category: 'ux-ui',
|
||||
title: 'Mobile Experience',
|
||||
description: 'Optimize for mobile users',
|
||||
prompt:
|
||||
'Review this codebase from a mobile-first perspective. What improvements would enhance the mobile user experience? Consider touch targets, responsive layouts, and mobile-specific interactions.',
|
||||
},
|
||||
{
|
||||
id: 'ux-feedback',
|
||||
category: 'ux-ui',
|
||||
title: 'User Feedback',
|
||||
description: 'Improve feedback and status indicators',
|
||||
prompt:
|
||||
'Analyze how this application communicates with users. Where are loading states, success messages, or error handling missing or unclear? What feedback would help users understand what is happening?',
|
||||
},
|
||||
|
||||
// DX prompts
|
||||
{
|
||||
id: 'dx-documentation',
|
||||
category: 'dx',
|
||||
title: 'Documentation Gaps',
|
||||
description: 'Identify missing documentation',
|
||||
prompt:
|
||||
'Review this codebase and identify areas lacking documentation. What would help new developers understand the architecture, APIs, and conventions? Consider inline comments, READMEs, and API docs.',
|
||||
},
|
||||
{
|
||||
id: 'dx-testing',
|
||||
category: 'dx',
|
||||
title: 'Testing Improvements',
|
||||
description: 'Enhance test coverage and quality',
|
||||
prompt:
|
||||
'Analyze the testing patterns in this codebase. What areas need better test coverage? What types of tests are missing? Consider unit tests, integration tests, and end-to-end tests.',
|
||||
},
|
||||
{
|
||||
id: 'dx-tooling',
|
||||
category: 'dx',
|
||||
title: 'Developer Tooling',
|
||||
description: 'Improve development workflows',
|
||||
prompt:
|
||||
'Review the development setup and tooling in this codebase. What improvements would speed up development? Consider build times, hot reload, debugging tools, and developer scripts.',
|
||||
},
|
||||
{
|
||||
id: 'dx-error-handling',
|
||||
category: 'dx',
|
||||
title: 'Error Handling',
|
||||
description: 'Improve error messages and debugging',
|
||||
prompt:
|
||||
'Analyze error handling in this codebase. Where are error messages unclear or missing? What would help developers debug issues faster? Consider logging, error boundaries, and stack traces.',
|
||||
},
|
||||
|
||||
// Growth prompts
|
||||
{
|
||||
id: 'growth-onboarding',
|
||||
category: 'growth',
|
||||
title: 'Onboarding Flow',
|
||||
description: 'Improve new user experience',
|
||||
prompt:
|
||||
"Analyze this application's onboarding experience. How can we help new users understand the value and get started quickly? Consider tutorials, progressive disclosure, and quick wins.",
|
||||
},
|
||||
{
|
||||
id: 'growth-engagement',
|
||||
category: 'growth',
|
||||
title: 'User Engagement',
|
||||
description: 'Increase user retention and activity',
|
||||
prompt:
|
||||
'Review this application and suggest features that would increase user engagement and retention. What would bring users back daily? Consider notifications, streaks, social features, and personalization.',
|
||||
},
|
||||
{
|
||||
id: 'growth-sharing',
|
||||
category: 'growth',
|
||||
title: 'Shareability',
|
||||
description: 'Make the app more shareable',
|
||||
prompt:
|
||||
'How can this application be made more shareable? What features would encourage users to invite others or share their work? Consider collaboration, public profiles, and export features.',
|
||||
},
|
||||
{
|
||||
id: 'growth-monetization',
|
||||
category: 'growth',
|
||||
title: 'Monetization Ideas',
|
||||
description: 'Identify potential revenue streams',
|
||||
prompt:
|
||||
'Based on this codebase, what features or tiers could support monetization? Consider premium features, usage limits, team features, and integrations that users would pay for.',
|
||||
},
|
||||
|
||||
// Technical prompts
|
||||
{
|
||||
id: 'tech-performance',
|
||||
category: 'technical',
|
||||
title: 'Performance Optimization',
|
||||
description: 'Identify performance bottlenecks',
|
||||
prompt:
|
||||
'Analyze this codebase for performance optimization opportunities. Where are the likely bottlenecks? Consider database queries, API calls, bundle size, rendering, and caching strategies.',
|
||||
},
|
||||
{
|
||||
id: 'tech-architecture',
|
||||
category: 'technical',
|
||||
title: 'Architecture Review',
|
||||
description: 'Evaluate and improve architecture',
|
||||
prompt:
|
||||
'Review the architecture of this codebase. What improvements would make it more maintainable, scalable, or testable? Consider separation of concerns, dependency management, and patterns.',
|
||||
},
|
||||
{
|
||||
id: 'tech-debt',
|
||||
category: 'technical',
|
||||
title: 'Technical Debt',
|
||||
description: 'Identify areas needing refactoring',
|
||||
prompt:
|
||||
'Identify technical debt in this codebase. What areas are becoming hard to maintain or understand? What refactoring would have the highest impact? Consider duplicated code, complexity, and outdated patterns.',
|
||||
},
|
||||
{
|
||||
id: 'tech-security',
|
||||
category: 'technical',
|
||||
title: 'Security Review',
|
||||
description: 'Identify security improvements',
|
||||
prompt:
|
||||
'Review this codebase for security improvements. What best practices are missing? Consider authentication, authorization, input validation, and data protection. Note: This is for improvement suggestions, not a security audit.',
|
||||
},
|
||||
|
||||
// Security prompts
|
||||
{
|
||||
id: 'security-auth',
|
||||
category: 'security',
|
||||
title: 'Authentication Security',
|
||||
description: 'Review authentication mechanisms',
|
||||
prompt:
|
||||
'Analyze the authentication system in this codebase. What security improvements would strengthen user authentication? Consider password policies, session management, MFA, and token handling.',
|
||||
},
|
||||
{
|
||||
id: 'security-data',
|
||||
category: 'security',
|
||||
title: 'Data Protection',
|
||||
description: 'Protect sensitive user data',
|
||||
prompt:
|
||||
'Review how this application handles sensitive data. What improvements would better protect user privacy? Consider encryption, data minimization, secure storage, and data retention policies.',
|
||||
},
|
||||
{
|
||||
id: 'security-input',
|
||||
category: 'security',
|
||||
title: 'Input Validation',
|
||||
description: 'Prevent injection attacks',
|
||||
prompt:
|
||||
'Analyze input handling in this codebase. Where could input validation be strengthened? Consider SQL injection, XSS, command injection, and file upload vulnerabilities.',
|
||||
},
|
||||
{
|
||||
id: 'security-api',
|
||||
category: 'security',
|
||||
title: 'API Security',
|
||||
description: 'Secure API endpoints',
|
||||
prompt:
|
||||
'Review the API security in this codebase. What improvements would make the API more secure? Consider rate limiting, authorization, CORS, and request validation.',
|
||||
},
|
||||
|
||||
// Performance prompts
|
||||
{
|
||||
id: 'perf-frontend',
|
||||
category: 'performance',
|
||||
title: 'Frontend Performance',
|
||||
description: 'Optimize UI rendering and loading',
|
||||
prompt:
|
||||
'Analyze the frontend performance of this application. What optimizations would improve load times and responsiveness? Consider bundle splitting, lazy loading, memoization, and render optimization.',
|
||||
},
|
||||
{
|
||||
id: 'perf-backend',
|
||||
category: 'performance',
|
||||
title: 'Backend Performance',
|
||||
description: 'Optimize server-side operations',
|
||||
prompt:
|
||||
'Review backend performance in this codebase. What optimizations would improve response times? Consider database queries, caching strategies, async operations, and resource pooling.',
|
||||
},
|
||||
{
|
||||
id: 'perf-database',
|
||||
category: 'performance',
|
||||
title: 'Database Optimization',
|
||||
description: 'Improve query performance',
|
||||
prompt:
|
||||
'Analyze database interactions in this codebase. What optimizations would improve data access performance? Consider indexing, query optimization, denormalization, and connection pooling.',
|
||||
},
|
||||
{
|
||||
id: 'perf-caching',
|
||||
category: 'performance',
|
||||
title: 'Caching Strategies',
|
||||
description: 'Implement effective caching',
|
||||
prompt:
|
||||
'Review caching opportunities in this application. Where would caching provide the most benefit? Consider API responses, computed values, static assets, and session data.',
|
||||
},
|
||||
|
||||
// Accessibility prompts
|
||||
{
|
||||
id: 'a11y-keyboard',
|
||||
category: 'accessibility',
|
||||
title: 'Keyboard Navigation',
|
||||
description: 'Enable full keyboard access',
|
||||
prompt:
|
||||
'Analyze keyboard accessibility in this codebase. What improvements would enable users to navigate entirely with keyboard? Consider focus management, tab order, and keyboard shortcuts.',
|
||||
},
|
||||
{
|
||||
id: 'a11y-screen-reader',
|
||||
category: 'accessibility',
|
||||
title: 'Screen Reader Support',
|
||||
description: 'Improve screen reader experience',
|
||||
prompt:
|
||||
'Review screen reader compatibility in this application. What improvements would help users with visual impairments? Consider ARIA labels, semantic HTML, live regions, and alt text.',
|
||||
},
|
||||
{
|
||||
id: 'a11y-visual',
|
||||
category: 'accessibility',
|
||||
title: 'Visual Accessibility',
|
||||
description: 'Improve visual design for all users',
|
||||
prompt:
|
||||
'Analyze visual accessibility in this codebase. What improvements would help users with visual impairments? Consider color contrast, text sizing, focus indicators, and reduced motion.',
|
||||
},
|
||||
{
|
||||
id: 'a11y-forms',
|
||||
category: 'accessibility',
|
||||
title: 'Accessible Forms',
|
||||
description: 'Make forms usable for everyone',
|
||||
prompt:
|
||||
'Review form accessibility in this application. What improvements would make forms more accessible? Consider labels, error messages, required field indicators, and input assistance.',
|
||||
},
|
||||
|
||||
// Analytics prompts
|
||||
{
|
||||
id: 'analytics-tracking',
|
||||
category: 'analytics',
|
||||
title: 'User Tracking',
|
||||
description: 'Track key user behaviors',
|
||||
prompt:
|
||||
'Analyze this application for analytics opportunities. What user behaviors should be tracked to understand engagement? Consider page views, feature usage, conversion funnels, and session duration.',
|
||||
},
|
||||
{
|
||||
id: 'analytics-metrics',
|
||||
category: 'analytics',
|
||||
title: 'Key Metrics',
|
||||
description: 'Define success metrics',
|
||||
prompt:
|
||||
'Based on this codebase, what key metrics should be tracked? Consider user acquisition, retention, engagement, and feature adoption. What dashboards would be most valuable?',
|
||||
},
|
||||
{
|
||||
id: 'analytics-errors',
|
||||
category: 'analytics',
|
||||
title: 'Error Monitoring',
|
||||
description: 'Track and analyze errors',
|
||||
prompt:
|
||||
'Review error handling in this codebase for monitoring opportunities. What error tracking would help identify and fix issues faster? Consider error aggregation, alerting, and stack traces.',
|
||||
},
|
||||
{
|
||||
id: 'analytics-performance',
|
||||
category: 'analytics',
|
||||
title: 'Performance Monitoring',
|
||||
description: 'Track application performance',
|
||||
prompt:
|
||||
'Analyze this application for performance monitoring opportunities. What metrics would help identify bottlenecks? Consider load times, API response times, and resource usage.',
|
||||
},
|
||||
];
|
||||
|
||||
export function getPromptsByCategory(category: IdeaCategory): IdeationPrompt[] {
|
||||
return GUIDED_PROMPTS.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
export function getPromptById(id: string): IdeationPrompt | undefined {
|
||||
return GUIDED_PROMPTS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function getCategoryById(id: IdeaCategory): PromptCategory | undefined {
|
||||
return PROMPT_CATEGORIES.find((c) => c.id === id);
|
||||
}
|
||||
208
apps/ui/src/components/views/ideation-view/index.tsx
Normal file
208
apps/ui/src/components/views/ideation-view/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* IdeationView - Main view for brainstorming and idea management
|
||||
* Dashboard-first design with Generate Ideas flow
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useIdeationStore } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { PromptCategoryGrid } from './components/prompt-category-grid';
|
||||
import { PromptList } from './components/prompt-list';
|
||||
import { IdeationDashboard } from './components/ideation-dashboard';
|
||||
import { getCategoryById } from './data/guided-prompts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import type { IdeationMode } from '@/store/ideation-store';
|
||||
|
||||
// Get subtitle text based on current mode
|
||||
function getSubtitle(currentMode: IdeationMode, selectedCategory: IdeaCategory | null): string {
|
||||
if (currentMode === 'dashboard') {
|
||||
return 'Review and accept generated ideas';
|
||||
}
|
||||
if (currentMode === 'prompts') {
|
||||
if (selectedCategory) {
|
||||
const categoryInfo = getCategoryById(selectedCategory);
|
||||
return `Select a prompt from ${categoryInfo?.name || 'category'}`;
|
||||
}
|
||||
return 'Select a category to generate ideas';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Breadcrumb component - compact inline breadcrumbs
|
||||
function IdeationBreadcrumbs({
|
||||
currentMode,
|
||||
selectedCategory,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
|
||||
}) {
|
||||
const categoryInfo = selectedCategory ? getCategoryById(selectedCategory) : null;
|
||||
|
||||
// On dashboard, no breadcrumbs needed (it's the root)
|
||||
if (currentMode === 'dashboard') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{selectedCategory && categoryInfo ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onNavigate('prompts', null)}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Generate Ideas
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className="text-foreground">{categoryInfo.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-foreground">Generate Ideas</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Header shown on all pages - matches other view headers
|
||||
function IdeationHeader({
|
||||
currentMode,
|
||||
selectedCategory,
|
||||
onNavigate,
|
||||
onGenerateIdeas,
|
||||
onBack,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
|
||||
onGenerateIdeas: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const subtitle = getSubtitle(currentMode, selectedCategory);
|
||||
const showBackButton = currentMode === 'prompts';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
{showBackButton && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-xl font-bold">Ideation</h1>
|
||||
</div>
|
||||
{currentMode === 'dashboard' ? (
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
<IdeationBreadcrumbs
|
||||
currentMode={currentMode}
|
||||
selectedCategory={selectedCategory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IdeationView() {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(mode: IdeationMode, category?: IdeaCategory | null) => {
|
||||
setMode(mode);
|
||||
if (category !== undefined) {
|
||||
setCategory(category);
|
||||
} else if (mode !== 'prompts') {
|
||||
setCategory(null);
|
||||
}
|
||||
},
|
||||
[setMode, setCategory]
|
||||
);
|
||||
|
||||
const handleSelectCategory = useCallback(
|
||||
(category: IdeaCategory) => {
|
||||
setCategory(category);
|
||||
},
|
||||
[setCategory]
|
||||
);
|
||||
|
||||
const handleBackFromPrompts = useCallback(() => {
|
||||
// If viewing a category, go back to category grid
|
||||
if (selectedCategory) {
|
||||
setCategory(null);
|
||||
return;
|
||||
}
|
||||
// Otherwise, go back to dashboard
|
||||
setMode('dashboard');
|
||||
}, [selectedCategory, setCategory, setMode]);
|
||||
|
||||
const handleGenerateIdeas = useCallback(() => {
|
||||
setMode('prompts');
|
||||
setCategory(null);
|
||||
}, [setMode, setCategory]);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center content-bg"
|
||||
data-testid="ideation-view"
|
||||
>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Open a project to start brainstorming ideas</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col content-bg min-h-0 overflow-hidden"
|
||||
data-testid="ideation-view"
|
||||
>
|
||||
{/* Header with breadcrumbs - always shown */}
|
||||
<IdeationHeader
|
||||
currentMode={currentMode}
|
||||
selectedCategory={selectedCategory}
|
||||
onNavigate={handleNavigate}
|
||||
onGenerateIdeas={handleGenerateIdeas}
|
||||
onBack={handleBackFromPrompts}
|
||||
/>
|
||||
|
||||
{/* Dashboard - main view */}
|
||||
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
|
||||
|
||||
{/* Prompts - category selection */}
|
||||
{currentMode === 'prompts' && !selectedCategory && (
|
||||
<PromptCategoryGrid onSelect={handleSelectCategory} onBack={handleBackFromPrompts} />
|
||||
)}
|
||||
|
||||
{/* Prompts - prompt selection within category */}
|
||||
{currentMode === 'prompts' && selectedCategory && (
|
||||
<PromptList category={selectedCategory} onBack={handleBackFromPrompts} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user