feat: show contextual navigation loading
This commit is contained in:
@@ -249,6 +249,15 @@ If startup fails after a schema migration, restore a pre-upgrade backup with the
|
|||||||
matching older image. Do not automatically downgrade the image over newer data.
|
matching older image. Do not automatically downgrade the image over newer data.
|
||||||
See `DEPLOYMENT.md` for the simplified Gitea workflow.
|
See `DEPLOYMENT.md` for the simplified Gitea workflow.
|
||||||
|
|
||||||
|
## Loading feedback
|
||||||
|
|
||||||
|
Initial rendering and language changes use a full-page indicator. Case-study
|
||||||
|
opening and next/previous navigation keep the drawer and its close control while
|
||||||
|
showing a loading indicator inside it. Indicators follow navigation/rendering
|
||||||
|
state without artificial delays or waiting for off-screen images and 3D scenes.
|
||||||
|
They follow the current theme, honor reduced motion, and expose localized status
|
||||||
|
labels under Languages in the editor.
|
||||||
|
|
||||||
## Build checks
|
## Build checks
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
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';
|
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||||
import { getPublicRoute } from '@/server/public';
|
return children;
|
||||||
export default async function ProjectsLayout({ children, }: { children: React.ReactNode; }) {
|
|
||||||
const { portfolio } = await getPublicRoute();
|
|
||||||
return <CaseStudyDrawer portfolio={portfolio}>{children}</CaseStudyDrawer>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||||
import { getPublicRoute } from '@/server/public';
|
return children;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { SiteRoot } from './SiteRoot';
|
import { SiteRoot } from './SiteRoot';
|
||||||
import { PortfolioPage } from './PortfolioPage';
|
import { PortfolioPage } from './PortfolioPage';
|
||||||
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
|
import { PortfolioRouteShell } from '@/components/PortfolioRouteShell';
|
||||||
import { getPublicRoute } from '@/server/public';
|
import { getPublicLanguage, getPublicRoute } from '@/server/public';
|
||||||
import { portfolioPath } from '@/content/contracts';
|
import { portfolioPath } from '@/content/contracts';
|
||||||
|
import { resolveMessages } from '@/content/messages';
|
||||||
|
import { InitialLoading } from '@/components/InitialLoading';
|
||||||
|
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
export async function publicMetadata(locale?: string): Promise<Metadata> {
|
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)])) } };
|
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 }) {
|
export async function PublicLayout({ children, locale }: { children: React.ReactNode; locale?: string }) {
|
||||||
const { portfolio } = await getPublicRoute(locale ? [locale] : []);
|
const language = await getPublicLanguage(locale);
|
||||||
return <SiteRoot locale={portfolio.locale} direction={portfolio.direction}>
|
const copy = resolveMessages(language.code, language.messages);
|
||||||
<PortfolioRouteShell homePath={portfolioPath(portfolio.locale, portfolio.defaultLanguage)}>
|
return <SiteRoot locale={language.code} direction={language.direction}>
|
||||||
<PortfolioPage portfolio={portfolio} />{children}
|
<Suspense fallback={<LoadingIndicator label={copy.loading.page} fullPage />}>
|
||||||
</PortfolioRouteShell>
|
<PublicContent locale={locale}>{children}</PublicContent>
|
||||||
|
</Suspense>
|
||||||
</SiteRoot>;
|
</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 { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
import type { PublicPortfolio } from '@/server/content';
|
import type { PublicPortfolio } from '@/server/content';
|
||||||
import type { Project } from '@/types/projects';
|
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 { X } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import type { PublicPortfolio } from '@/server/content';
|
import type { PublicPortfolio } from '@/server/content';
|
||||||
import { useDismissCaseStudy } from './PortfolioRouteShell';
|
import { useDismissCaseStudy } from './PortfolioNavigation';
|
||||||
import { CaseStudyShare } from './CaseStudyShare';
|
import { CaseStudyShare } from './CaseStudyShare';
|
||||||
import { useDrawerDrag } from './useDrawerDrag';
|
import { useDrawerDrag } from './useDrawerDrag';
|
||||||
import styles from './CaseStudyDrawer.module.css';
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import { portfolioPath } from '@/content/contracts';
|
import { portfolioPath } from '@/content/contracts';
|
||||||
import Link from 'next/link';
|
import { PortfolioLink as Link } from './PortfolioNavigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { languageFlag } from '@/content/language-flags';
|
import { languageFlag } from '@/content/language-flags';
|
||||||
import { Check, Globe, Languages, Moon, Sun } from 'lucide-react';
|
import { Check, Globe, Languages, Moon, Sun } from 'lucide-react';
|
||||||
@@ -50,7 +50,7 @@ export function HeroControls({ portfolio }: PortfolioProps) {
|
|||||||
{portfolio.languages.map(language => {
|
{portfolio.languages.map(language => {
|
||||||
const flag = languageFlag(language.code, language.flagCountry);
|
const flag = languageFlag(language.code, language.flagCountry);
|
||||||
return <Link key={language.code}
|
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}>
|
aria-current={locale === language.code ? 'page' : undefined} className={styles.languageOption}>
|
||||||
<span className={styles.languageOptionLabel}>
|
<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} />}
|
{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';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import type { PublicPortfolio } from '@/server/content';
|
||||||
const PortfolioRouteContext = createContext<(() => void) | null>(null);
|
import { PortfolioNavigationContext } from './PortfolioNavigation';
|
||||||
|
import { CaseStudyDrawer } from './CaseStudyDrawer';
|
||||||
|
import { CaseStudyLoading } from './CaseStudyLoading';
|
||||||
|
import { LoadingIndicator } from './LoadingIndicator';
|
||||||
|
|
||||||
export function PortfolioRouteShell({
|
export function PortfolioRouteShell({
|
||||||
children,
|
children,
|
||||||
homePath,
|
homePath,
|
||||||
|
background,
|
||||||
|
portfolio,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
homePath: string;
|
homePath: string;
|
||||||
|
background: ReactNode;
|
||||||
|
portfolio: PublicPortfolio;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
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 previousPath = useRef(pathname);
|
||||||
const canReturnWithBack = useRef(false);
|
const canReturnWithBack = useRef(false);
|
||||||
const returnScrollY = useRef<number | null>(null);
|
const returnScrollY = useRef<number | null>(null);
|
||||||
const previousScrollRestoration = useRef<ScrollRestoration>('auto');
|
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(() => {
|
useEffect(() => {
|
||||||
if (pathname === homePath) {
|
if (pathname === homePath) {
|
||||||
canReturnWithBack.current = false;
|
canReturnWithBack.current = false;
|
||||||
@@ -51,22 +70,35 @@ export function PortfolioRouteShell({
|
|||||||
}, [homePath, pathname]);
|
}, [homePath, pathname]);
|
||||||
|
|
||||||
const dismissCaseStudy = useCallback(() => {
|
const dismissCaseStudy = useCallback(() => {
|
||||||
if (canReturnWithBack.current) {
|
setKind('dismiss');
|
||||||
router.back();
|
setTarget(homePath);
|
||||||
return;
|
startTransition(() => {
|
||||||
}
|
if (canReturnWithBack.current) router.back();
|
||||||
router.replace(homePath, { scroll: false });
|
else router.replace(homePath, { scroll: false });
|
||||||
|
});
|
||||||
}, [homePath, router]);
|
}, [homePath, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortfolioRouteContext.Provider value={dismissCaseStudy}>
|
<PortfolioNavigationContext.Provider value={{
|
||||||
{children}
|
dismiss: dismissCaseStudy,
|
||||||
</PortfolioRouteContext.Provider>
|
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 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 { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||||
import { projectPath } from '@/content/contracts';
|
import { projectPath } from '@/content/contracts';
|
||||||
import type { PortfolioProps } from '@/server/content';
|
import type { PortfolioProps } from '@/server/content';
|
||||||
|
|||||||
@@ -95,5 +95,12 @@
|
|||||||
"linkedin": "LinkedIn",
|
"linkedin": "LinkedIn",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"telegram": "Telegram"
|
"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": "لینکدین",
|
"linkedin": "لینکدین",
|
||||||
"github": "گیتهاب",
|
"github": "گیتهاب",
|
||||||
"telegram": "تلگرام"
|
"telegram": "تلگرام"
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"page": "در حال بارگذاری نمونهکارها…",
|
||||||
|
"project": "در حال بارگذاری مطالعه موردی…",
|
||||||
|
"language": "در حال تغییر زبان…",
|
||||||
|
"error": "بارگذاری این صفحه ممکن نشد.",
|
||||||
|
"retry": "تلاش دوباره"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,23 @@ import 'server-only';
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { connection } from 'next/server';
|
import { connection } from 'next/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { getDatabase } from './db';
|
import { getDatabase } from './db';
|
||||||
|
import { languages, siteSettings } from './schema';
|
||||||
import { ContentError, readContent, resolvePublicRoute } from './content';
|
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 () => {
|
export const readPublicSnapshot = cache(async () => {
|
||||||
await connection();
|
await connection();
|
||||||
return readContent(getDatabase());
|
return readContent(getDatabase());
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test('localized project routes use runtime content and metadata', async () => {
|
|||||||
test('project cards separate case study navigation from live links', async () => {
|
test('project cards separate case study navigation from live links', async () => {
|
||||||
const source = await readFile('src/components/ProjectsSection.tsx', 'utf8');
|
const source = await readFile('src/components/ProjectsSection.tsx', 'utf8');
|
||||||
|
|
||||||
assert.match(source, /import Link from ['"]next\/link['"]/);
|
assert.match(source, /PortfolioLink as Link/);
|
||||||
assert.match(source, /scroll=\{false\}/);
|
assert.match(source, /scroll=\{false\}/);
|
||||||
assert.match(source, /target="_blank"/);
|
assert.match(source, /target="_blank"/);
|
||||||
assert.match(source, /rel="noopener noreferrer"/);
|
assert.match(source, /rel="noopener noreferrer"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user