F(frontend): add route loading feedback
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-20 11:47:27 +03:30
parent f2d5b92b22
commit 5711961b9b
12 changed files with 312 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
import { DetailPageLoading } from "@/components/page-loading";
export default function Loading() {
return <DetailPageLoading />;
}

View File

@@ -1,10 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { PublicApiError, getPublicPost } from "@/lib/public-api"; import { PublicApiError, getPublicPost } from "@/lib/public-api";
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site"; import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
import { formatJalali } from "@/lib/utils"; import { formatJalali } from "@/lib/utils";
@@ -109,7 +109,7 @@ export default async function BlogDetailPage({
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between gap-3"> <div className="mb-6 flex items-center justify-between gap-3">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href="/blog">بازگشت به وبلاگ</Link> <Link to="/blog">بازگشت به وبلاگ</Link>
</Button> </Button>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{formatJalali(post.published_at || post.created_at, false)} {formatJalali(post.published_at || post.created_at, false)}

5
src/app/blog/loading.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ListingPageLoading } from "@/components/page-loading";
export default function Loading() {
return <ListingPageLoading />;
}

View File

@@ -0,0 +1,5 @@
import { DetailPageLoading } from "@/components/page-loading";
export default function Loading() {
return <DetailPageLoading />;
}

View File

@@ -0,0 +1,5 @@
import { ListingPageLoading } from "@/components/page-loading";
export default function Loading() {
return <ListingPageLoading />;
}

View File

@@ -1,8 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { Suspense } from "react";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Providers from "@/components/providers"; import Providers from "@/components/providers";
import RouteProgress from "@/components/RouteProgress";
import { siteUrl } from "@/lib/site"; import { siteUrl } from "@/lib/site";
import "../index.css"; import "../index.css";
@@ -33,6 +35,9 @@ export default function RootLayout({
<html lang="fa" dir="rtl" suppressHydrationWarning> <html lang="fa" dir="rtl" suppressHydrationWarning>
<body className={`${vazirmatn.variable} font-sans antialiased`}> <body className={`${vazirmatn.variable} font-sans antialiased`}>
<Providers> <Providers>
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
<Navbar /> <Navbar />
{children} {children}
<Footer /> <Footer />

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
export default function NotFound() { export default function NotFound() {
return ( return (
@@ -12,7 +12,7 @@ export default function NotFound() {
آدرسی که وارد کردهاید وجود ندارد یا جابهجا شده است. آدرسی که وارد کردهاید وجود ندارد یا جابهجا شده است.
</p> </p>
<Button asChild> <Button asChild>
<Link href="/">بازگشت به خانه</Link> <Link to="/">بازگشت به خانه</Link>
</Button> </Button>
</div> </div>
); );

View File

@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import {
completeNavigationProgress,
subscribeNavigationProgress,
} from "@/lib/navigation-progress";
const START_VALUE = 18;
const MAX_ACTIVE_VALUE = 90;
export default function RouteProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const intervalRef = React.useRef<number | null>(null);
const finishTimeoutRef = React.useRef<number | null>(null);
const safetyTimeoutRef = React.useRef<number | null>(null);
const activeRef = React.useRef(false);
const routeKey = `${pathname ?? ""}?${searchParams?.toString() ?? ""}`;
const clearTimers = React.useCallback(() => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (finishTimeoutRef.current !== null) {
window.clearTimeout(finishTimeoutRef.current);
finishTimeoutRef.current = null;
}
if (safetyTimeoutRef.current !== null) {
window.clearTimeout(safetyTimeoutRef.current);
safetyTimeoutRef.current = null;
}
}, []);
const finish = React.useCallback(() => {
if (!activeRef.current) {
return;
}
activeRef.current = false;
clearTimers();
setProgress(100);
finishTimeoutRef.current = window.setTimeout(() => {
setVisible(false);
setProgress(0);
}, 220);
}, [clearTimers]);
const start = React.useCallback(() => {
clearTimers();
activeRef.current = true;
setVisible(true);
setProgress(START_VALUE);
window.requestAnimationFrame(() => {
setProgress(28);
});
intervalRef.current = window.setInterval(() => {
setProgress((current) => {
if (current >= MAX_ACTIVE_VALUE) {
return current;
}
const delta = Math.max((MAX_ACTIVE_VALUE - current) * 0.14, 1.5);
return Math.min(MAX_ACTIVE_VALUE, current + delta);
});
}, 180);
safetyTimeoutRef.current = window.setTimeout(() => {
finish();
}, 12000);
}, [clearTimers, finish]);
React.useEffect(() => {
return subscribeNavigationProgress((event) => {
if (event === "start") {
start();
return;
}
finish();
});
}, [finish, start]);
React.useEffect(() => {
finish();
}, [finish, routeKey]);
React.useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 z-[100] h-1 overflow-hidden"
>
<div
className={`absolute inset-0 transition-opacity duration-200 ${
visible ? "opacity-100" : "opacity-0"
}`}
>
<div
className="h-full origin-right bg-[linear-gradient(90deg,hsl(var(--primary))_0%,hsl(var(--route-progress))_100%)] shadow-[0_0_16px_hsl(var(--route-progress)/0.55)] transition-[transform] duration-200 ease-out"
style={{ transform: `scaleX(${progress / 100})` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Skeleton } from "@/components/ui/skeleton";
export function ListingPageLoading() {
return (
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<Skeleton className="mb-8 h-10 w-40" />
<Skeleton className="mb-8 h-10 w-full max-w-md" />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="overflow-hidden rounded-lg border bg-card"
>
<Skeleton className="aspect-video w-full rounded-none" />
<div className="space-y-4 p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-6 flex-1" />
<Skeleton className="h-6 w-16" />
</div>
<Skeleton className="h-4 w-32" />
<div className="space-y-3 pt-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function DetailPageLoading() {
return (
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between gap-3">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-5 w-28" />
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Skeleton className="aspect-video w-full rounded-none" />
<div className="space-y-6 p-6">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-10 w-4/5" />
<Skeleton className="h-4 w-40" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-5/6" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -53,6 +53,7 @@ All colors MUST be HSL.
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
--route-progress: 199 89% 48%;
} }
.dark { .dark {
@@ -91,6 +92,7 @@ All colors MUST be HSL.
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
--route-progress: 198 93% 60%;
} }
} }

View File

@@ -0,0 +1,35 @@
type ProgressEvent = "start" | "done";
type Listener = (event: ProgressEvent) => void;
const listeners = new Set<Listener>();
let active = false;
function emit(event: ProgressEvent) {
listeners.forEach((listener) => listener(event));
}
export function subscribeNavigationProgress(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function startNavigationProgress() {
if (active) {
return;
}
active = true;
emit("start");
}
export function completeNavigationProgress() {
if (!active) {
return;
}
active = false;
emit("done");
}

View File

@@ -7,6 +7,10 @@ import {
usePathname, usePathname,
useRouter, useRouter,
} from "next/navigation"; } from "next/navigation";
import {
completeNavigationProgress,
startNavigationProgress,
} from "@/lib/navigation-progress";
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & { type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string; to: string;
@@ -26,9 +30,53 @@ type NavLinkProps = Omit<LinkProps, "className"> & {
className?: string | ((state: { isActive: boolean }) => string); className?: string | ((state: { isActive: boolean }) => string);
}; };
export function Link({ to, replace, prefetch, children, ...props }: LinkProps) { function isPlainLeftClick(event: React.MouseEvent<HTMLAnchorElement>) {
return ( return (
<NextLink href={to} replace={replace} prefetch={prefetch} {...props}> event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
function shouldTrackNavigation(to: string) {
if (typeof window === "undefined") {
return true;
}
try {
const current = new URL(window.location.href);
const target = new URL(to, window.location.href);
return current.origin === target.origin && current.pathname !== target.pathname;
} catch {
return true;
}
}
export function Link({ to, replace, prefetch, children, onClick, target, ...props }: LinkProps) {
return (
<NextLink
href={to}
replace={replace}
prefetch={prefetch}
target={target}
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
target === "_blank" ||
!isPlainLeftClick(event) ||
!shouldTrackNavigation(to)
) {
return;
}
startNavigationProgress();
}}
{...props}
>
{children} {children}
</NextLink> </NextLink>
); );
@@ -69,6 +117,7 @@ export function useNavigate(): NavigateFunction {
return React.useCallback( return React.useCallback(
(to: string | number, options?: { replace?: boolean }) => { (to: string | number, options?: { replace?: boolean }) => {
if (typeof to === "number") { if (typeof to === "number") {
startNavigationProgress();
if (to === -1) { if (to === -1) {
router.back(); router.back();
return; return;
@@ -79,6 +128,12 @@ export function useNavigate(): NavigateFunction {
return; return;
} }
if (shouldTrackNavigation(to)) {
startNavigationProgress();
} else {
completeNavigationProgress();
}
if (options?.replace) { if (options?.replace) {
router.replace(to); router.replace(to);
return; return;