style: add fade-in transition from skeleton to content
Smooth out the jarring visual pop when loading skeletons are replaced by real content. Only animates after an actual skeleton was shown — cached data still renders instantly with no delay.
This commit is contained in:
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
|
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
|
||||||
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
|
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
|
||||||
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.
|
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.
|
||||||
- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look.
|
- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
||||||
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
||||||
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { NotificationsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -31,6 +31,7 @@ export default function NotificationsPage() {
|
|||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(loading)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
const fetchPage = async (pageOffset: number, append: boolean) => {
|
const fetchPage = async (pageOffset: number, append: boolean) => {
|
||||||
if (append) setLoadingMore(true)
|
if (append) setLoadingMore(true)
|
||||||
@@ -104,7 +105,7 @@ export default function NotificationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 ${fadeClass}`}>
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Locations from '@/components/dashboard/Locations'
|
|||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||||
|
|
||||||
@@ -198,6 +198,7 @@ export default function PublicDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -274,7 +275,7 @@ export default function PublicDashboardPage() {
|
|||||||
const safeScreenResolutions = screen_resolutions || []
|
const safeScreenResolutions = screen_resolutions || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className={`min-h-screen ${fadeClass}`}>
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { FunnelChart } from '@/components/ui/funnel-chart'
|
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
@@ -62,6 +62,7 @@ export default function FunnelReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading && !funnel)
|
const showSkeleton = useMinimumLoading(loading && !funnel)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <FunnelDetailSkeleton />
|
return <FunnelDetailSkeleton />
|
||||||
@@ -113,7 +114,7 @@ export default function FunnelReportPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
import { deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||||
import { useFunnels } from '@/lib/swr/dashboard'
|
import { useFunnels } from '@/lib/swr/dashboard'
|
||||||
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function FunnelsPage() {
|
export default function FunnelsPage() {
|
||||||
@@ -28,13 +28,14 @@ export default function FunnelsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
|
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <FunnelsListSkeleton />
|
return <FunnelsListSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getDateRange, formatDate } from '@ciphera-net/ui'
|
|||||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||||
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
||||||
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||||
import { JourneysSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
useDashboard,
|
useDashboard,
|
||||||
useJourneyTransitions,
|
useJourneyTransitions,
|
||||||
@@ -53,6 +53,7 @@ export default function JourneysPage() {
|
|||||||
}, [dashboard?.site?.domain])
|
}, [dashboard?.site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
|
const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
const entryPointOptions = [
|
const entryPointOptions = [
|
||||||
{ value: '', label: 'All entry points' },
|
{ value: '', label: 'All entry points' },
|
||||||
@@ -65,7 +66,7 @@ export default function JourneysPage() {
|
|||||||
if (showSkeleton) return <JourneysSkeleton />
|
if (showSkeleton) return <JourneysSkeleton />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { toast } from '@ciphera-net/ui'
|
|||||||
import { Button } from '@ciphera-net/ui'
|
import { Button } from '@ciphera-net/ui'
|
||||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import FilterBar from '@/components/dashboard/FilterBar'
|
import FilterBar from '@/components/dashboard/FilterBar'
|
||||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||||
import Chart from '@/components/dashboard/Chart'
|
import Chart from '@/components/dashboard/Chart'
|
||||||
@@ -423,6 +423,7 @@ export default function SiteDashboardPage() {
|
|||||||
// Skip the minimum-loading skeleton when SWR already has cached data
|
// Skip the minimum-loading skeleton when SWR already has cached data
|
||||||
// (prevents the 300ms flash when navigating back to the dashboard)
|
// (prevents the 300ms flash when navigating back to the dashboard)
|
||||||
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -437,7 +438,7 @@ export default function SiteDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
|
|||||||
import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
|
import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import VerificationModal from '@/components/sites/VerificationModal'
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
import { PasswordInput } from '@ciphera-net/ui'
|
import { PasswordInput } from '@ciphera-net/ui'
|
||||||
@@ -509,6 +509,7 @@ export default function SiteSettingsPage() {
|
|||||||
}, [site?.domain])
|
}, [site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(siteLoading && !site)
|
const showSkeleton = useMinimumLoading(siteLoading && !site)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return (
|
return (
|
||||||
@@ -542,7 +543,7 @@ export default function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { toast } from '@ciphera-net/ui'
|
|||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { Button, Modal } from '@ciphera-net/ui'
|
import { Button, Modal } from '@ciphera-net/ui'
|
||||||
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -645,6 +645,7 @@ export default function UptimePage() {
|
|||||||
}, [site?.domain])
|
}, [site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
|
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) return <UptimeSkeleton />
|
if (showSkeleton) return <UptimeSkeleton />
|
||||||
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
||||||
@@ -654,7 +655,7 @@ export default function UptimePage() {
|
|||||||
const overallStatus = uptimeData?.status ?? 'operational'
|
const overallStatus = uptimeData?.status ?? 'operational'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
|
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
|
||||||
|
|
||||||
export { useMinimumLoading } from './useMinimumLoading'
|
export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading'
|
||||||
|
|
||||||
// ─── Primitives ──────────────────────────────────────────────
|
// ─── Primitives ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,19 @@ export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
|
|||||||
|
|
||||||
return show
|
return show
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns 'animate-fade-in' when transitioning from skeleton to content,
|
||||||
|
* empty string otherwise. Prevents the jarring visual "pop" when skeletons
|
||||||
|
* are replaced by real content, without adding unnecessary animation when
|
||||||
|
* data loads from cache (no skeleton shown).
|
||||||
|
*/
|
||||||
|
export function useSkeletonFade(showSkeleton: boolean): string {
|
||||||
|
const wasEverLoading = useRef(false)
|
||||||
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
wasEverLoading.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !showSkeleton && wasEverLoading.current ? 'animate-fade-in' : ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ const config: Config = {
|
|||||||
'50%': { backgroundColor: 'var(--highlight)' },
|
'50%': { backgroundColor: 'var(--highlight)' },
|
||||||
'100%': { backgroundColor: 'transparent' },
|
'100%': { backgroundColor: 'transparent' },
|
||||||
},
|
},
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'cell-highlight': 'cell-highlight 0.5s ease forwards',
|
'cell-highlight': 'cell-highlight 0.5s ease forwards',
|
||||||
'cell-flash': 'cell-flash 0.6s ease forwards',
|
'cell-flash': 'cell-flash 0.6s ease forwards',
|
||||||
|
'fade-in': 'fade-in 150ms ease-out',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
||||||
|
|||||||
Reference in New Issue
Block a user