feat: show contextual navigation loading

This commit is contained in:
2026-09-16 15:10:37 +03:30
parent 9119eb6c29
commit 083a4e0b7b
23 changed files with 242 additions and 40 deletions

View File

@@ -0,0 +1,2 @@
'use client';
export { PortfolioError as default } from '@/components/PortfolioError';

View File

@@ -0,0 +1 @@
export { CaseStudyLoading as default } from '@/components/CaseStudyLoading';

View File

@@ -1,6 +1,3 @@
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
import { getPublicRoute } from '@/server/public';
export default async function ProjectsLayout({ children, }: { children: React.ReactNode; }) {
const { portfolio } = await getPublicRoute();
return <CaseStudyDrawer portfolio={portfolio}>{children}</CaseStudyDrawer>;
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,2 @@
'use client';
export { PortfolioError as default } from '@/components/PortfolioError';

View File

@@ -0,0 +1 @@
export { CaseStudyLoading as default } from '@/components/CaseStudyLoading';

View File

@@ -1,6 +1,3 @@
import { CaseStudyDrawer } from '@/components/CaseStudyDrawer';
import { getPublicRoute } from '@/server/public';
export default async function ProjectsLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) {
const { portfolio } = await getPublicRoute([(await params).locale]);
return <CaseStudyDrawer portfolio={portfolio}>{children}</CaseStudyDrawer>;
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,9 +1,13 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { SiteRoot } from './SiteRoot';
import { PortfolioPage } from './PortfolioPage';
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
import { getPublicRoute } from '@/server/public';
import { getPublicLanguage, getPublicRoute } from '@/server/public';
import { portfolioPath } from '@/content/contracts';
import { resolveMessages } from '@/content/messages';
import { InitialLoading } from '@/components/InitialLoading';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import '@/styles/globals.css';
export async function publicMetadata(locale?: string): Promise<Metadata> {
@@ -12,10 +16,21 @@ export async function publicMetadata(locale?: string): Promise<Metadata> {
return { ...portfolio.metadata, alternates: { canonical, languages: Object.fromEntries(portfolio.languages.map(language => [language.code, portfolioPath(language.code, portfolio.defaultLanguage)])) } };
}
export async function PublicLayout({ children, locale }: { children: React.ReactNode; locale?: string }) {
const { portfolio } = await getPublicRoute(locale ? [locale] : []);
return <SiteRoot locale={portfolio.locale} direction={portfolio.direction}>
<PortfolioRouteShell homePath={portfolioPath(portfolio.locale, portfolio.defaultLanguage)}>
<PortfolioPage portfolio={portfolio} />{children}
</PortfolioRouteShell>
const language = await getPublicLanguage(locale);
const copy = resolveMessages(language.code, language.messages);
return <SiteRoot locale={language.code} direction={language.direction}>
<Suspense fallback={<LoadingIndicator label={copy.loading.page} fullPage />}>
<PublicContent locale={locale}>{children}</PublicContent>
</Suspense>
</SiteRoot>;
}
async function PublicContent({ children, locale }: { children: React.ReactNode; locale?: string }) {
const { portfolio } = await getPublicRoute(locale ? [locale] : []);
return <>
<InitialLoading label={portfolio.messages.loading.page} />
<PortfolioRouteShell homePath={portfolioPath(portfolio.locale, portfolio.defaultLanguage)} portfolio={portfolio} background={<PortfolioPage portfolio={portfolio} />}>
{children}
</PortfolioRouteShell>
</>;
}

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { PortfolioLink as Link } from './PortfolioNavigation';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import type { PublicPortfolio } from '@/server/content';
import type { Project } from '@/types/projects';

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } fro
import { X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import type { PublicPortfolio } from '@/server/content';
import { useDismissCaseStudy } from './PortfolioRouteShell';
import { useDismissCaseStudy } from './PortfolioNavigation';
import { CaseStudyShare } from './CaseStudyShare';
import { useDrawerDrag } from './useDrawerDrag';
import styles from './CaseStudyDrawer.module.css';

View File

@@ -0,0 +1,13 @@
'use client';
import { LoadingIndicator } from './LoadingIndicator';
import { usePortfolioNavigation } from './PortfolioNavigation';
import styles from './LoadingIndicator.module.css';
export function CaseStudyLoading() {
const { loadingLabel } = usePortfolioNavigation();
return <>
<h1 id="case-study-title" className={styles.hidden}>{loadingLabel}</h1>
<div id="case-study-introduction"><LoadingIndicator label={loadingLabel} /></div>
</>;
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import { portfolioPath } from '@/content/contracts';
import Link from 'next/link';
import { PortfolioLink as Link } from './PortfolioNavigation';
import Image from 'next/image';
import { languageFlag } from '@/content/language-flags';
import { Check, Globe, Languages, Moon, Sun } from 'lucide-react';
@@ -50,7 +50,7 @@ export function HeroControls({ portfolio }: PortfolioProps) {
{portfolio.languages.map(language => {
const flag = languageFlag(language.code, language.flagCountry);
return <Link key={language.code}
href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
kind="language" href={portfolioPath(language.code, portfolio.defaultLanguage)} hrefLang={language.code} role="menuitem"
aria-current={locale === language.code ? 'page' : undefined} className={styles.languageOption}>
<span className={styles.languageOptionLabel}>
{flag ? <Image src={flag} alt="" aria-hidden="true" width={20} height={20} unoptimized className={styles.languageFlag} /> : <Globe size={20} aria-hidden="true" className={styles.languageFlag} />}

View File

@@ -0,0 +1,17 @@
'use client';
import { useSyncExternalStore } from 'react';
import { LoadingIndicator } from './LoadingIndicator';
const subscribe = () => () => {};
const ready = () => true;
const waiting = () => false;
export function InitialLoading({ label }: { label: string }) {
const hydrated = useSyncExternalStore(subscribe, ready, waiting);
if (hydrated) return null;
return <div data-initial-loading>
<LoadingIndicator label={label} fullPage />
<noscript><style>{'[data-initial-loading] { display: none; }'}</style></noscript>
</div>;
}

View File

@@ -0,0 +1,38 @@
.indicator {
display: flex;
min-height: 15rem;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
color: var(--text-primary);
background: var(--bg-page);
}
.fullPage {
position: fixed;
inset: 0;
z-index: 1000;
}
.spinner {
width: 2rem;
height: 2rem;
color: var(--action-link);
animation: turn 900ms linear infinite;
}
.hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes turn { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}

View File

@@ -0,0 +1,9 @@
import { LoaderCircle } from 'lucide-react';
import styles from './LoadingIndicator.module.css';
export function LoadingIndicator({ label, fullPage = false }: { label: string; fullPage?: boolean }) {
return <div className={`${styles.indicator} ${fullPage ? styles.fullPage : ''}`} role="status" aria-live="polite" aria-busy="true">
<LoaderCircle className={styles.spinner} aria-hidden="true" />
<span>{label}</span>
</div>;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { useParams } from 'next/navigation';
import { dictionaries } from '@/content/messages';
import styles from './LoadingIndicator.module.css';
export function PortfolioError({ retry }: { retry: () => void }) {
const { locale } = useParams<{ locale?: string }>();
const copy = (dictionaries[locale ?? 'en'] ?? dictionaries.en).loading;
return <div className={styles.indicator} role="alert">
<h1 id="case-study-title">{copy.error}</h1>
<button type="button" onClick={retry}>{copy.retry}</button>
</div>;
}

View File

@@ -0,0 +1,27 @@
'use client';
import { createContext, useContext, type ComponentProps } from 'react';
import Link from 'next/link';
type NavigationOptions = { replace?: boolean; scroll?: boolean; kind?: 'project' | 'language' };
export const PortfolioNavigationContext = createContext<{
navigate: (href: string, options: NavigationOptions) => void;
dismiss: () => void;
loadingLabel: string;
} | null>(null);
export function usePortfolioNavigation() {
const navigation = useContext(PortfolioNavigationContext);
if (!navigation) throw new Error('Portfolio navigation requires PortfolioRouteShell');
return navigation;
}
export function useDismissCaseStudy() { return usePortfolioNavigation().dismiss; }
export function PortfolioLink({ href, kind = 'project', replace, scroll, ...props }: Omit<ComponentProps<typeof Link>, 'href' | 'onNavigate'> & { href: string; kind?: 'project' | 'language' }) {
const { navigate } = usePortfolioNavigation();
return <Link {...props} href={href} replace={replace} scroll={scroll} onNavigate={event => {
event.preventDefault();
navigate(href, { kind, replace, scroll });
}} />;
}

View File

@@ -1,31 +1,50 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
useTransition,
type ReactNode,
} from 'react';
import { usePathname, useRouter } from 'next/navigation';
const PortfolioRouteContext = createContext<(() => void) | null>(null);
import type { PublicPortfolio } from '@/server/content';
import { PortfolioNavigationContext } from './PortfolioNavigation';
import { CaseStudyDrawer } from './CaseStudyDrawer';
import { CaseStudyLoading } from './CaseStudyLoading';
import { LoadingIndicator } from './LoadingIndicator';
export function PortfolioRouteShell({
children,
homePath,
background,
portfolio,
}: {
children: ReactNode;
homePath: string;
background: ReactNode;
portfolio: PublicPortfolio;
}) {
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [target, setTarget] = useState<string | null>(null);
const [kind, setKind] = useState<'project' | 'language' | 'dismiss'>('project');
const pending = isPending && target !== null;
const languagePending = pending && kind === 'language';
const showDrawer = pathname !== homePath || (pending && kind === 'project');
const previousPath = useRef(pathname);
const canReturnWithBack = useRef(false);
const returnScrollY = useRef<number | null>(null);
const previousScrollRestoration = useRef<ScrollRestoration>('auto');
useEffect(() => {
const onPageShow = (event: PageTransitionEvent) => { if (event.persisted) setTarget(null); };
window.addEventListener('pageshow', onPageShow);
return () => window.removeEventListener('pageshow', onPageShow);
}, []);
useEffect(() => {
if (pathname === homePath) {
canReturnWithBack.current = false;
@@ -51,22 +70,35 @@ export function PortfolioRouteShell({
}, [homePath, pathname]);
const dismissCaseStudy = useCallback(() => {
if (canReturnWithBack.current) {
router.back();
return;
}
router.replace(homePath, { scroll: false });
setKind('dismiss');
setTarget(homePath);
startTransition(() => {
if (canReturnWithBack.current) router.back();
else router.replace(homePath, { scroll: false });
});
}, [homePath, router]);
return (
<PortfolioRouteContext.Provider value={dismissCaseStudy}>
{children}
</PortfolioRouteContext.Provider>
<PortfolioNavigationContext.Provider value={{
dismiss: dismissCaseStudy,
loadingLabel: portfolio.messages.loading.project,
navigate: (href, options) => {
if (href === pathname && !pending) return;
setTarget(href);
setKind(options.kind ?? 'project');
startTransition(() => {
if (options.replace) router.replace(href, { scroll: options.scroll });
else router.push(href, { scroll: options.scroll });
});
},
}}>
<div inert={languagePending} aria-busy={languagePending || undefined}>
{background}
{showDrawer ? <CaseStudyDrawer portfolio={portfolio}>
{pending && kind === 'project' ? <CaseStudyLoading /> : children}
</CaseStudyDrawer> : children}
</div>
{languagePending && <LoadingIndicator label={portfolio.messages.loading.language} fullPage />}
</PortfolioNavigationContext.Provider>
);
}
export function useDismissCaseStudy() {
const dismiss = useContext(PortfolioRouteContext);
if (!dismiss) throw new Error('useDismissCaseStudy must be used within PortfolioRouteShell');
return dismiss;
}

View File

@@ -3,7 +3,7 @@ import { formatMessage } from '@/content/contracts';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { PortfolioLink as Link } from './PortfolioNavigation';
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
import { projectPath } from '@/content/contracts';
import type { PortfolioProps } from '@/server/content';

View File

@@ -95,5 +95,12 @@
"linkedin": "LinkedIn",
"github": "GitHub",
"telegram": "Telegram"
},
"loading": {
"page": "Loading portfolio…",
"project": "Loading case study…",
"language": "Changing language…",
"error": "This page could not be loaded.",
"retry": "Try again"
}
}

View File

@@ -95,5 +95,12 @@
"linkedin": "لینکدین",
"github": "گیت‌هاب",
"telegram": "تلگرام"
},
"loading": {
"page": "در حال بارگذاری نمونه‌کارها…",
"project": "در حال بارگذاری مطالعه موردی…",
"language": "در حال تغییر زبان…",
"error": "بارگذاری این صفحه ممکن نشد.",
"retry": "تلاش دوباره"
}
}

View File

@@ -2,9 +2,23 @@ import 'server-only';
import { cache } from 'react';
import { connection } from 'next/server';
import { notFound } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { getDatabase } from './db';
import { languages, siteSettings } from './schema';
import { ContentError, readContent, resolvePublicRoute } from './content';
// Resolve only document attributes before streaming the portfolio content.
export async function getPublicLanguage(locale?: string) {
await connection();
const { db } = getDatabase();
const settings = db.select().from(siteSettings).get()?.data;
if (!settings) throw new ContentError('Database is not initialized', 503);
const code = locale ?? settings.defaultLanguage;
const language = db.select().from(languages).where(eq(languages.code, code)).get()?.data;
if (!language?.enabled || locale === settings.defaultLanguage) notFound();
return language;
}
export const readPublicSnapshot = cache(async () => {
await connection();
return readContent(getDatabase());