chore: session auto-commit
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { AttachmentStats } from "../../../shared/api/client";
|
import type { AttachmentStats } from "../../../shared/api/client";
|
||||||
import { cn } from "../../../shared/lib/utils";
|
import { cn } from "../../../shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -13,14 +14,99 @@ interface AttachmentStatsPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UPLOAD_COLORS = {
|
||||||
|
uploaded: { color: "#38bdf8", label: "Uploaded" },
|
||||||
|
pending: { color: "#facc15", label: "Pending" },
|
||||||
|
failed: { color: "#f472b6", label: "Failed" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function UploadDonut({
|
||||||
|
uploaded,
|
||||||
|
pending,
|
||||||
|
failed,
|
||||||
|
total,
|
||||||
|
}: { uploaded: number; pending: number; failed: number; total: number }) {
|
||||||
|
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||||
|
const size = 120;
|
||||||
|
const strokeWidth = 20;
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const center = size / 2;
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{ key: "uploaded" as const, ...UPLOAD_COLORS.uploaded, value: uploaded },
|
||||||
|
{ key: "pending" as const, ...UPLOAD_COLORS.pending, value: pending },
|
||||||
|
{ key: "failed" as const, ...UPLOAD_COLORS.failed, value: failed },
|
||||||
|
].filter((e) => e.value > 0);
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
const segments = entries.map((e) => {
|
||||||
|
const offset = cumulative;
|
||||||
|
const length = (e.value / total) * circumference;
|
||||||
|
cumulative += length;
|
||||||
|
return { ...e, length, offset };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(var(--muted))"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
opacity={0.15}
|
||||||
|
/>
|
||||||
|
{segments.map((seg) => {
|
||||||
|
const isHovered = hoveredKey === seg.key;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={seg.key}
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={seg.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
|
||||||
|
strokeDashoffset={-seg.offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-200",
|
||||||
|
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHoveredKey(seg.key)}
|
||||||
|
onMouseLeave={() => setHoveredKey(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-lg font-bold tabular-nums leading-none">
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
|
||||||
|
</div>
|
||||||
|
{hoveredKey && (
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10 pointer-events-none">
|
||||||
|
{(() => {
|
||||||
|
const e = entries.find((en) => en.key === hoveredKey);
|
||||||
|
if (!e) return null;
|
||||||
|
return `${e.label}: ${e.value}`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AttachmentStatsPanel({
|
export function AttachmentStatsPanel({
|
||||||
stats,
|
stats,
|
||||||
loading,
|
loading,
|
||||||
}: AttachmentStatsPanelProps) {
|
}: AttachmentStatsPanelProps) {
|
||||||
if (loading && !stats) {
|
if (loading && !stats) return <LoadingBox />;
|
||||||
return <LoadingBox />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats || stats.total_attachments === 0) {
|
if (!stats || stats.total_attachments === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -35,53 +121,18 @@ export function AttachmentStatsPanel({
|
|||||||
stats.total_attachments > 0
|
stats.total_attachments > 0
|
||||||
? Math.round((stats.uploaded / stats.total_attachments) * 100)
|
? Math.round((stats.uploaded / stats.total_attachments) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const failedPct =
|
const failedPct =
|
||||||
stats.total_attachments > 0
|
stats.total_attachments > 0
|
||||||
? Math.round((stats.failed / stats.total_attachments) * 100)
|
? Math.round((stats.failed / stats.total_attachments) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const totalSizeMB = stats.total_size_bytes / (1024 * 1024);
|
const totalSizeMB = stats.total_size_bytes / (1024 * 1024);
|
||||||
|
|
||||||
const cards = [
|
const metricCards = [
|
||||||
{
|
{ label: "Total Media", value: formatNum(stats.total_attachments), accent: "text-foreground" },
|
||||||
label: "Total Media",
|
|
||||||
value: formatNum(stats.total_attachments),
|
|
||||||
accent: "text-foreground",
|
|
||||||
},
|
|
||||||
{ label: "Upload Success", value: `${uploadPct}%`, accent: "text-primary" },
|
{ label: "Upload Success", value: `${uploadPct}%`, accent: "text-primary" },
|
||||||
{ label: "Gagal Upload", value: `${failedPct}%`, accent: "text-accent" },
|
{ label: "Gagal Upload", value: `${failedPct}%`, accent: "text-accent" },
|
||||||
{
|
{ label: "Total Ukuran", value: `${totalSizeMB.toFixed(1)} MB`, accent: "text-muted-foreground" },
|
||||||
label: "Total Ukuran",
|
{ label: "Pengupload", value: formatNum(stats.unique_uploaders), accent: "text-primary" },
|
||||||
value: `${totalSizeMB.toFixed(1)} MB`,
|
|
||||||
accent: "text-muted-foreground",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pengupload",
|
|
||||||
value: formatNum(stats.unique_uploaders),
|
|
||||||
accent: "text-primary",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusCards = [
|
|
||||||
{
|
|
||||||
label: "Uploaded",
|
|
||||||
value: formatNum(stats.uploaded),
|
|
||||||
total: stats.total_attachments,
|
|
||||||
barClass: "bg-gradient-to-r from-primary to-teal-300",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pending",
|
|
||||||
value: formatNum(stats.pending),
|
|
||||||
total: stats.total_attachments,
|
|
||||||
barClass: "bg-yellow-400/60",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Failed",
|
|
||||||
value: formatNum(stats.failed),
|
|
||||||
total: stats.total_attachments,
|
|
||||||
barClass: "bg-accent/70",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,12 +157,12 @@ export function AttachmentStatsPanel({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-4">
|
||||||
{cards.map((c) => (
|
{metricCards.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.label}
|
key={c.label}
|
||||||
className="rounded-xl border border-sky-100 bg-white p-3"
|
className="rounded-lg border border-muted/50 bg-white p-3"
|
||||||
>
|
>
|
||||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{c.label}
|
{c.label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -126,28 +177,56 @@ export function AttachmentStatsPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Donut + status bars */}
|
||||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||||
Status Upload
|
<UploadDonut
|
||||||
</h4>
|
uploaded={stats.uploaded}
|
||||||
{statusCards.map((s) => (
|
pending={stats.pending}
|
||||||
<div key={s.label} className="flex items-center gap-2">
|
failed={stats.failed}
|
||||||
<span className="w-20 text-[10px] font-medium text-muted-foreground">
|
total={stats.total_attachments}
|
||||||
{s.label}
|
/>
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-2 overflow-hidden rounded-full bg-sky-50">
|
{/* Status legend with inline bars */}
|
||||||
<div
|
<div className="flex-1 w-full space-y-2">
|
||||||
className={cn("h-full rounded-full", s.barClass)}
|
{[
|
||||||
style={{
|
{
|
||||||
width: `${(Number(s.value.replace(/\./g, "")) / Math.max(s.total, 1)) * 100}%`,
|
key: "uploaded",
|
||||||
}}
|
...UPLOAD_COLORS.uploaded,
|
||||||
/>
|
value: stats.uploaded,
|
||||||
</div>
|
},
|
||||||
<span className="w-10 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
{
|
||||||
{s.value}
|
key: "pending",
|
||||||
</span>
|
...UPLOAD_COLORS.pending,
|
||||||
</div>
|
value: stats.pending,
|
||||||
))}
|
},
|
||||||
|
{
|
||||||
|
key: "failed",
|
||||||
|
...UPLOAD_COLORS.failed,
|
||||||
|
value: stats.failed,
|
||||||
|
},
|
||||||
|
].map((s) => {
|
||||||
|
const pct = (s.value / stats.total_attachments) * 100;
|
||||||
|
return (
|
||||||
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
|
<span className="w-20 text-[10px] font-medium text-muted-foreground truncate">
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-2 overflow-hidden rounded-md bg-muted/30">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-md transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: s.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -163,7 +242,7 @@ function LoadingBox() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||||
<span className="ml-2">Memuat data...</span>
|
<span className="ml-2">Memuat data...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ interface TopicListProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopicList({ topics, loading }: TopicListProps) {
|
const TOPIC_COLORS = [
|
||||||
if (loading && !topics?.length) {
|
"from-primary to-sky-300",
|
||||||
return <LoadingBox />;
|
"from-accent to-pink-300",
|
||||||
}
|
"from-orange-400 to-yellow-300",
|
||||||
|
"from-emerald-400 to-teal-300",
|
||||||
|
"from-violet-400 to-purple-300",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TopicList({ topics, loading }: TopicListProps) {
|
||||||
|
if (loading && !topics?.length) return <LoadingBox />;
|
||||||
if (!topics?.length) {
|
if (!topics?.length) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -44,31 +49,40 @@ export function TopicList({ topics, loading }: TopicListProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ScrollArea className="max-h-[260px]">
|
<ScrollArea className="max-h-[260px]">
|
||||||
<div className="divide-y divide-sky-50">
|
<div className="divide-y divide-muted/30">
|
||||||
{topics.map((topic, i) => (
|
{topics.map((topic, i) => {
|
||||||
<div
|
const colorClass =
|
||||||
key={topic.topic}
|
TOPIC_COLORS[i % TOPIC_COLORS.length];
|
||||||
className="flex items-center gap-3 px-5 py-2 text-sm border-l-2 border-l-primary"
|
return (
|
||||||
>
|
<div
|
||||||
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
key={topic.topic}
|
||||||
{i + 1}
|
className="flex items-center gap-3 px-5 py-2.5 text-sm border-l-2"
|
||||||
</span>
|
style={{
|
||||||
<span className="flex-1 truncate font-medium">
|
borderLeftColor: `hsl(${199 + i * 35}, 80%, 50%)`,
|
||||||
{topic.topic}
|
}}
|
||||||
</span>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-sky-50">
|
{i + 1}
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-primary to-pink-300"
|
|
||||||
style={{ width: `${(topic.count / maxCount) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
|
||||||
{topic.count}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex-1 truncate font-medium text-xs">
|
||||||
|
{topic.topic}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-16 overflow-hidden rounded-md bg-muted/30">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-md bg-gradient-to-r ${colorClass}`}
|
||||||
|
style={{
|
||||||
|
width: `${(topic.count / maxCount) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{topic.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -80,7 +94,7 @@ function LoadingBox() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||||
<span className="ml-2">Memuat data...</span>
|
<span className="ml-2">Memuat data...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ interface UserTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserTable({ users, loading }: UserTableProps) {
|
export function UserTable({ users, loading }: UserTableProps) {
|
||||||
if (loading && !users?.length) {
|
if (loading && !users?.length) return <LoadingBox />;
|
||||||
return <LoadingBox />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!users?.length) {
|
if (!users?.length) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -49,7 +46,7 @@ export function UserTable({ users, loading }: UserTableProps) {
|
|||||||
<ScrollArea className="max-h-[260px]">
|
<ScrollArea className="max-h-[260px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="sticky top-0 z-10 bg-white border-b border-sky-100 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="sticky top-0 z-10 bg-white border-b border-muted/50 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||||
<th className="py-2 pr-2 font-semibold">User</th>
|
<th className="py-2 pr-2 font-semibold">User</th>
|
||||||
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
||||||
@@ -57,13 +54,13 @@ export function UserTable({ users, loading }: UserTableProps) {
|
|||||||
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-sky-50">
|
<tbody className="divide-y divide-muted/20">
|
||||||
{users.map((user, i) => (
|
{users.map((user, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={user.user_id}
|
key={user.user_id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors rounded-xl",
|
"transition-colors",
|
||||||
i % 2 === 0 ? "bg-white" : "bg-sky-50/20",
|
i % 2 === 0 ? "bg-white" : "bg-muted/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
@@ -75,11 +72,11 @@ export function UserTable({ users, loading }: UserTableProps) {
|
|||||||
<img
|
<img
|
||||||
src={user.avatar_url}
|
src={user.avatar_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-6 w-6 rounded-full ring-2 ring-sky-100"
|
className="h-6 w-6 rounded-md ring-1 ring-muted"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-primary">
|
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-[10px] font-bold text-primary">
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -90,15 +87,15 @@ export function UserTable({ users, loading }: UserTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-2 text-right">
|
<td className="py-1.5 pr-2 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-sky-50">
|
<div className="h-1.5 w-14 overflow-hidden rounded-sm bg-muted/30">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-gradient-to-r from-primary to-pink-300"
|
className="h-full rounded-sm bg-gradient-to-r from-primary to-sky-300"
|
||||||
style={{
|
style={{
|
||||||
width: `${(user.message_count / maxMsgs) * 100}%`,
|
width: `${(user.message_count / maxMsgs) * 100}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs tabular-nums">
|
<span className="font-mono text-xs tabular-nums text-foreground">
|
||||||
{user.message_count}
|
{user.message_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +131,7 @@ function LoadingBox() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||||
<span className="ml-2">Memuat data...</span>
|
<span className="ml-2">Memuat data...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user