[PULSE-60] Frontend hardening, UX polish, and security #35

Merged
uz1mani merged 41 commits from staging into main 2026-02-22 21:43:06 +00:00
24 changed files with 836 additions and 200 deletions
Showing only changes of commit d571b6156f - Show all commits

View File

@@ -16,7 +16,7 @@ import {
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
import { NotificationsListSkeleton } from '@/components/skeletons'
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
@@ -30,6 +30,7 @@ export default function NotificationsPage() {
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const showSkeleton = useMinimumLoading(loading)
const fetchPage = async (pageOffset: number, append: boolean) => {
if (append) setLoadingMore(true)
@@ -128,7 +129,7 @@ export default function NotificationsPage() {
</Link>
</p>
{loading ? (
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">

View File

@@ -13,7 +13,7 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton } from '@/components/skeletons'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
// Helper to get date ranges
@@ -193,7 +193,9 @@ export default function PublicDashboardPage() {
loadDashboard()
}
if (loading && !data && !isPasswordProtected) {
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
if (showSkeleton) {
return <DashboardSkeleton />
}

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { FunnelDetailSkeleton } from '@/components/skeletons'
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
@@ -92,7 +92,9 @@ export default function FunnelReportPage() {
}
}
if (loading && !funnel) {
const showSkeleton = useMinimumLoading(loading && !funnel)
if (showSkeleton) {
return <FunnelDetailSkeleton />
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton } from '@/components/skeletons'
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
export default function FunnelsPage() {
@@ -44,7 +44,9 @@ export default function FunnelsPage() {
}
}
if (loading) {
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <FunnelsListSkeleton />
}

View File

@@ -11,7 +11,7 @@ import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
import { DashboardSkeleton } from '@/components/skeletons'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -216,7 +216,9 @@ export default function SiteDashboardPage() {
return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
if (loading) {
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <DashboardSkeleton />
}

View File

@@ -7,7 +7,7 @@ import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton } from '@/components/skeletons'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
@@ -91,7 +91,9 @@ export default function RealtimePage() {
}
}
if (loading) return <RealtimeSkeleton />
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (

View File

@@ -6,7 +6,7 @@ import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoData
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton } from '@/components/skeletons'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui'
@@ -317,7 +317,9 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
if (loading) {
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">

View File

@@ -21,7 +21,7 @@ import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton } from '@/components/skeletons'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import {
AreaChart,
Area,
@@ -703,7 +703,9 @@ export default function UptimePage() {
setShowEditModal(true)
}
if (loading) return <UptimeSkeleton />
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []

View File

@@ -6,6 +6,8 @@
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
export { useMinimumLoading } from './useMinimumLoading'
// ─── Primitives ──────────────────────────────────────────────
export function SkeletonLine({ className = '' }: { className?: string }) {

View File

@@ -0,0 +1,34 @@
'use client'
import { useState, useEffect, useRef } from 'react'
/**
* Prevents skeleton flicker on fast loads by keeping it visible
* for at least `minMs` once it appears.
*
* @param loading - The raw loading state from data fetching
* @param minMs - Minimum milliseconds the skeleton stays visible (default 300)
* @returns Whether the skeleton should be shown
*/
export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
const [show, setShow] = useState(loading)
const startRef = useRef<number>(loading ? Date.now() : 0)
useEffect(() => {
if (loading) {
startRef.current = Date.now()
setShow(true)
} else {
const elapsed = Date.now() - startRef.current
const remaining = minMs - elapsed
if (remaining > 0) {
const timer = setTimeout(() => setShow(false), remaining)
return () => clearTimeout(timer)
} else {
setShow(false)
}
}
}, [loading, minMs])
return show
}