feat: show contextual navigation loading
This commit is contained in:
2
src/app/(en)/projects/[projectId]/error.tsx
Normal file
2
src/app/(en)/projects/[projectId]/error.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
'use client';
|
||||
export { PortfolioError as default } from '@/components/PortfolioError';
|
||||
1
src/app/(en)/projects/[projectId]/loading.tsx
Normal file
1
src/app/(en)/projects/[projectId]/loading.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { CaseStudyLoading as default } from '@/components/CaseStudyLoading';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
'use client';
|
||||
export { PortfolioError as default } from '@/components/PortfolioError';
|
||||
@@ -0,0 +1 @@
|
||||
export { CaseStudyLoading as default } from '@/components/CaseStudyLoading';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
13
src/components/CaseStudyLoading.tsx
Normal file
13
src/components/CaseStudyLoading.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
17
src/components/InitialLoading.tsx
Normal file
17
src/components/InitialLoading.tsx
Normal 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>;
|
||||
}
|
||||
38
src/components/LoadingIndicator.module.css
Normal file
38
src/components/LoadingIndicator.module.css
Normal 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; }
|
||||
}
|
||||
9
src/components/LoadingIndicator.tsx
Normal file
9
src/components/LoadingIndicator.tsx
Normal 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>;
|
||||
}
|
||||
14
src/components/PortfolioError.tsx
Normal file
14
src/components/PortfolioError.tsx
Normal 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>;
|
||||
}
|
||||
27
src/components/PortfolioNavigation.tsx
Normal file
27
src/components/PortfolioNavigation.tsx
Normal 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 });
|
||||
}} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,5 +95,12 @@
|
||||
"linkedin": "لینکدین",
|
||||
"github": "گیتهاب",
|
||||
"telegram": "تلگرام"
|
||||
},
|
||||
"loading": {
|
||||
"page": "در حال بارگذاری نمونهکارها…",
|
||||
"project": "در حال بارگذاری مطالعه موردی…",
|
||||
"language": "در حال تغییر زبان…",
|
||||
"error": "بارگذاری این صفحه ممکن نشد.",
|
||||
"retry": "تلاش دوباره"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user