mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
refactor: unify docker deployment and restructure frontend architecture
This commit implements a unified Docker deployment strategy, moving from separate frontend and backend images to a single, multi-stage build image containing both services. It also introduces a major reorganization of the frontend directory structure and simplifies the environment configuration. Key changes: - **Deployment**: Added a multi-stage `Dockerfile` and `docker-entrypoint.sh` to package the Go backend and Nginx-served frontend into a single container. - **CI/CD**: Updated GitHub Actions workflows (`ci-cd.yml`, `release.yml`) to build and push the new unified image instead of separate ones. - **Frontend Refactor**: Reorganized `frontend/src/pages` into a domain-driven directory structure (e.g., `auth/`, `admin/`, `content/`, `communication/`, `productivity/`, `settings/`, `misc/`). - **Configuration**: Simplified `.env.example` and updated `docker-compose.yml` to reflect the unified service model and single host port. - **Cleanup**: Removed deprecated `docker-compose.demo.yml`, `docker-compose.prod.yml`, and various unused frontend components and services. - **Backend**: Refactored configuration loading to use exported `GetDurationEnv` for better consistency.
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import {
|
||||
IconChartLine,
|
||||
IconBookmarks,
|
||||
IconChecklist,
|
||||
IconClock,
|
||||
IconTarget,
|
||||
IconBrain,
|
||||
IconGitBranch,
|
||||
IconBulb,
|
||||
IconAward
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
interface AnalyticsData {
|
||||
period: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
days: number;
|
||||
};
|
||||
summary: {
|
||||
hours_tracked: number;
|
||||
tasks_completed: number;
|
||||
bookmarks_added: number;
|
||||
notes_created: number;
|
||||
courses_completed: number;
|
||||
github_commits: number;
|
||||
};
|
||||
analytics: Array<{
|
||||
date: string;
|
||||
hours_tracked: number;
|
||||
tasks_completed: number;
|
||||
bookmarks_added: number;
|
||||
notes_created: number;
|
||||
courses_completed: number;
|
||||
github_commits: number;
|
||||
study_streak: number;
|
||||
productivity_score: number;
|
||||
}>;
|
||||
productivity_metrics: Array<{
|
||||
period: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
total_hours: number;
|
||||
billable_hours: number;
|
||||
non_billable_hours: number;
|
||||
tasks_completed: number;
|
||||
average_task_time: number;
|
||||
peak_productivity_hour: number;
|
||||
focus_score: number;
|
||||
efficiency_score: number;
|
||||
}>;
|
||||
learning_analytics: Array<{
|
||||
id: number;
|
||||
course: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
start_date: string;
|
||||
last_accessed: string;
|
||||
time_spent: number;
|
||||
progress: number;
|
||||
modules_completed: number;
|
||||
total_modules: number;
|
||||
average_score: number;
|
||||
streak_days: number;
|
||||
skills_acquired: string[];
|
||||
}>;
|
||||
github_analytics: Array<{
|
||||
date: string;
|
||||
commits: number;
|
||||
pull_requests: number;
|
||||
issues_opened: number;
|
||||
issues_closed: number;
|
||||
reviews: number;
|
||||
contributions: number;
|
||||
languages: Record<string, number>;
|
||||
repositories: string[];
|
||||
}>;
|
||||
goals: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
target_value: number;
|
||||
current_value: number;
|
||||
unit: string;
|
||||
deadline: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
progress: number;
|
||||
is_completed: boolean;
|
||||
milestones: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
target_value: number;
|
||||
current_value: number;
|
||||
deadline: string;
|
||||
status: string;
|
||||
is_completed: boolean;
|
||||
}>;
|
||||
}>;
|
||||
habit_analytics: Array<{
|
||||
habit_name: string;
|
||||
start_date: string;
|
||||
last_completed: string;
|
||||
streak: number;
|
||||
best_streak: number;
|
||||
total_days: number;
|
||||
completion_rate: number;
|
||||
frequency: string;
|
||||
category: string;
|
||||
goal_target: number;
|
||||
goal_achieved: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const Analytics = () => {
|
||||
const haptics = useHaptics();
|
||||
const [analytics, setAnalytics] = createSignal<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = createSignal('30');
|
||||
|
||||
const createFallbackAnalyticsData = (): AnalyticsData => ({
|
||||
period: {
|
||||
start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end_date: new Date().toISOString(),
|
||||
days: 30,
|
||||
},
|
||||
summary: {
|
||||
hours_tracked: 0,
|
||||
tasks_completed: 0,
|
||||
bookmarks_added: 0,
|
||||
notes_created: 0,
|
||||
courses_completed: 0,
|
||||
github_commits: 0,
|
||||
},
|
||||
analytics: [],
|
||||
productivity_metrics: [],
|
||||
learning_analytics: [],
|
||||
github_analytics: [],
|
||||
goals: [],
|
||||
habit_analytics: [],
|
||||
});
|
||||
|
||||
const normalizeAnalyticsData = (raw: any): AnalyticsData => {
|
||||
const fallback = createFallbackAnalyticsData();
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const periodRaw = raw.period && typeof raw.period === 'object' ? raw.period : {};
|
||||
const summaryRaw = raw.summary && typeof raw.summary === 'object' ? raw.summary : {};
|
||||
|
||||
return {
|
||||
period: {
|
||||
start_date: typeof periodRaw.start_date === 'string' ? periodRaw.start_date : fallback.period.start_date,
|
||||
end_date: typeof periodRaw.end_date === 'string' ? periodRaw.end_date : fallback.period.end_date,
|
||||
days: Number(periodRaw.days) || fallback.period.days,
|
||||
},
|
||||
summary: {
|
||||
hours_tracked: Number(summaryRaw.hours_tracked) || 0,
|
||||
tasks_completed: Number(summaryRaw.tasks_completed) || 0,
|
||||
bookmarks_added: Number(summaryRaw.bookmarks_added) || 0,
|
||||
notes_created: Number(summaryRaw.notes_created) || 0,
|
||||
courses_completed: Number(summaryRaw.courses_completed) || 0,
|
||||
github_commits: Number(summaryRaw.github_commits) || 0,
|
||||
},
|
||||
analytics: Array.isArray(raw.analytics) ? raw.analytics : [],
|
||||
productivity_metrics: Array.isArray(raw.productivity_metrics) ? raw.productivity_metrics : [],
|
||||
learning_analytics: Array.isArray(raw.learning_analytics) ? raw.learning_analytics : [],
|
||||
github_analytics: Array.isArray(raw.github_analytics) ? raw.github_analytics : [],
|
||||
goals: Array.isArray(raw.goals) ? raw.goals : [],
|
||||
habit_analytics: Array.isArray(raw.habit_analytics) ? raw.habit_analytics : [],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch analytics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAnalytics(normalizeAnalyticsData(data));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setAnalytics(createFallbackAnalyticsData());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fetchAnalytics();
|
||||
});
|
||||
|
||||
const formatHours = (hours: number) => {
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent': return 'text-destructive';
|
||||
case 'high': return 'text-orange-500';
|
||||
case 'medium': return 'text-yellow-500';
|
||||
case 'low': return 'text-muted-foreground';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Component render
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Analytics & Insights</h1>
|
||||
<p class="text-muted-foreground">Track your productivity and progress</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={selectedPeriod()}
|
||||
onChange={(e) => {
|
||||
setSelectedPeriod(e.target.value);
|
||||
haptics.selection();
|
||||
}}
|
||||
class="px-3 py-2 border rounded-md bg-background"
|
||||
>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
</select>
|
||||
<Button onClick={() => {
|
||||
fetchAnalytics();
|
||||
haptics.selection();
|
||||
}}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="bg-destructive/15 border border-destructive/20 rounded-md p-4">
|
||||
<p class="text-destructive">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-2 text-muted-foreground">Loading analytics...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={analytics()}>
|
||||
<div class="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Hours Tracked</p>
|
||||
<p class="text-2xl font-bold">{formatHours(analytics()!.summary.hours_tracked)}</p>
|
||||
</div>
|
||||
<IconClock class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tasks Completed</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.tasks_completed}</p>
|
||||
</div>
|
||||
<IconChecklist class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Bookmarks Added</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.bookmarks_added}</p>
|
||||
</div>
|
||||
<IconBookmarks class="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">GitHub Commits</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
|
||||
</div>
|
||||
<IconGitBranch class="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Goals Progress */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconTarget class="h-5 w-5" />
|
||||
Active Goals
|
||||
</CardTitle>
|
||||
<CardDescription>Track your goal progress</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<For each={analytics()!.goals.filter(g => g.status === 'active').slice(0, 5)}>
|
||||
{(goal) => (
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium">{goal.title}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{goal.current_value} / {goal.target_value} {goal.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`text-sm font-medium ${getPriorityColor(goal.priority)}`}>
|
||||
{goal.priority}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{Math.round(goal.progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${goal.progress}%`}
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Deadline: {formatDate(goal.deadline)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.goals.filter(g => g.status === 'active').length === 0}>
|
||||
<p class="text-muted-foreground text-center py-4">No active goals</p>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit Tracking */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconAward class="h-5 w-5" />
|
||||
Habit Tracking
|
||||
</CardTitle>
|
||||
<CardDescription>Your daily habits and streaks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<For each={analytics()!.habit_analytics.slice(0, 5)}>
|
||||
{(habit) => (
|
||||
<div class="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium">{habit.habit_name}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{habit.frequency} • {Math.round(habit.completion_rate)}% completion
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconBulb class="h-4 w-4 text-orange-500" />
|
||||
<span class="font-medium">{habit.streak} day streak</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Best: {habit.best_streak} days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.habit_analytics.length === 0}>
|
||||
<p class="text-muted-foreground text-center py-4">No habits tracked</p>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Learning Progress */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconBrain class="h-5 w-5" />
|
||||
Learning Progress
|
||||
</CardTitle>
|
||||
<CardDescription>Your course progress and achievements</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={analytics()!.learning_analytics.slice(0, 6)}>
|
||||
{(course) => (
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium truncate">{course.course.title}</h4>
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
{course.modules_completed}/{course.total_modules} modules
|
||||
</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${course.progress}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{Math.round(course.progress)}% complete</span>
|
||||
<span>{course.streak_days} day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.learning_analytics.length === 0}>
|
||||
<div class="col-span-full text-center py-8">
|
||||
<p class="text-muted-foreground">No courses in progress</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconGitBranch class="h-5 w-5" />
|
||||
GitHub Activity
|
||||
</CardTitle>
|
||||
<CardDescription>Your contribution summary</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
|
||||
<p class="text-sm text-muted-foreground">Commits</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.pull_requests, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Pull Requests</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.issues_opened, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Issues Opened</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.reviews, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Reviews</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={analytics()!.github_analytics.slice(0, 7)}>
|
||||
{(day) => (
|
||||
<div class="flex justify-between items-center p-2 border rounded">
|
||||
<span class="text-sm">{formatDate(day.date)}</span>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span>{day.commits} commits</span>
|
||||
<span>{day.pull_requests} PRs</span>
|
||||
<span>{day.issues_opened} issues</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Productivity Insights */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconChartLine class="h-5 w-5" />
|
||||
Productivity Insights
|
||||
</CardTitle>
|
||||
<CardDescription>Key insights and patterns</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Daily Activity</h4>
|
||||
<div class="space-y-2">
|
||||
<For each={analytics()!.analytics.slice(0, 7)}>
|
||||
{(day) => (
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm">{formatDate(day.date)}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">{formatHours(day.hours_tracked)}</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{day.tasks_completed} tasks
|
||||
</span>
|
||||
<Show when={day.productivity_score > 0}>
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">
|
||||
{Math.round(day.productivity_score)}%
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Key Metrics</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Average Daily Hours</span>
|
||||
<span class="text-sm font-medium">
|
||||
{formatHours(analytics()!.summary.hours_tracked / analytics()!.period.days)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Tasks per Day</span>
|
||||
<span class="text-sm font-medium">
|
||||
{(analytics()!.summary.tasks_completed / analytics()!.period.days).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Study Streak</span>
|
||||
<span class="text-sm font-medium">
|
||||
{Math.max(...analytics()!.analytics.map(a => a.study_streak))} days
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Average Productivity</span>
|
||||
<span class="text-sm font-medium">
|
||||
{Math.round(
|
||||
analytics()!.analytics.reduce((sum, a) => sum + a.productivity_score, 0) /
|
||||
analytics()!.analytics.length
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user