Improve page load times and rendering
This commit is contained in:
@@ -24,7 +24,15 @@ const Avatar = memo<AvatarProps>(
|
||||
|
||||
return (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
|
||||
<img src={src} alt={alt} className={baseStyles} {...props} />
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={baseStyles}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import { caseStudyVisualPath, getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import MutualAidArt from "../../../../public/assets/case-study/case-study-mutual-aid.svg";
|
||||
import FoodNotBombsArt from "../../../../public/assets/case-study/case-study-food-not-bombs.svg";
|
||||
import BoulderCountyStreetMedicsArt from "../../../../public/assets/case-study/case-study-boulder-county-street-medics.svg";
|
||||
import type { CaseStudyProps } from "./CaseStudy.types";
|
||||
|
||||
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
|
||||
@@ -11,11 +13,23 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
|
||||
rose: "bg-[var(--color-surface-invert-brand-red)]",
|
||||
};
|
||||
|
||||
/** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */
|
||||
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
|
||||
lavender: getAssetPath(caseStudyVisualPath("lavender")),
|
||||
neutral: getAssetPath(caseStudyVisualPath("neutral")),
|
||||
rose: getAssetPath(caseStudyVisualPath("rose")),
|
||||
/**
|
||||
* Inline SVGR components avoid the network round-trip the prior `next/image`
|
||||
* version required, so the illustration paints with the colored tile shell.
|
||||
*/
|
||||
const SURFACE_ART: Record<
|
||||
CaseStudyProps["surface"],
|
||||
ComponentType<SVGProps<SVGSVGElement>>
|
||||
> = {
|
||||
lavender: MutualAidArt,
|
||||
neutral: FoodNotBombsArt,
|
||||
rose: BoulderCountyStreetMedicsArt,
|
||||
};
|
||||
|
||||
const SURFACE_ART_DATA_KEY: Record<CaseStudyProps["surface"], string> = {
|
||||
lavender: "case-study-mutual-aid",
|
||||
neutral: "case-study-food-not-bombs",
|
||||
rose: "case-study-boulder-county-street-medics",
|
||||
};
|
||||
|
||||
/** Figma: ~23px corner (“Card / CaseStudy” shells). */
|
||||
@@ -27,6 +41,7 @@ function CaseStudyView({
|
||||
visual,
|
||||
className = "",
|
||||
}: CaseStudyProps) {
|
||||
const Art = SURFACE_ART[surface];
|
||||
return (
|
||||
<div
|
||||
data-figma-node="21993-32352"
|
||||
@@ -35,14 +50,13 @@ function CaseStudyView({
|
||||
{visual ? (
|
||||
<div className="flex size-full items-center justify-center p-2">{visual}</div>
|
||||
) : (
|
||||
<Image
|
||||
src={SURFACE_ART[surface]}
|
||||
alt={imageAlt}
|
||||
<Art
|
||||
role="img"
|
||||
aria-label={imageAlt}
|
||||
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
|
||||
width={305}
|
||||
height={305}
|
||||
unoptimized
|
||||
className="pointer-events-none size-full select-none object-contain object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
|
||||
import TopWithPathname from "./Top/TopWithPathname";
|
||||
|
||||
/**
|
||||
* Marketing-only navigation. Skips the server-side `getNavAuthSignedIn()` call
|
||||
* so marketing pages can render statically (no `force-dynamic`); `TopWithPathname`
|
||||
* fetches `/api/auth/session` on mount and updates the header from "Log in" to
|
||||
* "Profile" when the user is signed in. Brief mismatch is acceptable here —
|
||||
* `(app)` / `(admin)` keep the server-rendered nav.
|
||||
*/
|
||||
const MarketingNavigation = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (isChromelessNavigationPath(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopWithPathname initialSignedIn={false} />;
|
||||
});
|
||||
|
||||
MarketingNavigation.displayName = "MarketingNavigation";
|
||||
|
||||
export default MarketingNavigation;
|
||||
@@ -13,7 +13,7 @@ import Button from "../../buttons/Button";
|
||||
import AvatarContainer from "../../asset/AvatarContainer";
|
||||
import Avatar from "../../asset/Avatar";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { TopView } from "./Top.view";
|
||||
import type { TopProps, NavSize } from "./Top.types";
|
||||
|
||||
@@ -51,15 +51,14 @@ const TopContainer = memo<TopProps>(
|
||||
/**
|
||||
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||
* this button is always clicked from outside the wizard. Clears anonymous
|
||||
* `localStorage` and, when backend sync is on, deletes the server draft
|
||||
* so signed-in users get the same fresh start as guests (see
|
||||
* {@link prepareFreshCreateFlowEntry}).
|
||||
* `localStorage` synchronously and, when backend sync is on, fires the
|
||||
* server `DELETE /api/drafts/me` in the background. `SignedInDraftHydration`
|
||||
* reads the `create:fresh-entry-pending` sentinel and waits before fetching
|
||||
* (see {@link prepareFreshCreateFlowEntrySync}).
|
||||
*/
|
||||
const handleCreateRuleClick = useCallback(() => {
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push("/create");
|
||||
})();
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
router.push("/create/informational");
|
||||
}, [router]);
|
||||
|
||||
// Schema markup for site navigation
|
||||
|
||||
@@ -21,7 +21,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
onError,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
// Variant configurations
|
||||
const variants: Record<string, VariantConfig> = {
|
||||
@@ -97,16 +96,13 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
const quoteId = `${baseId}-content`;
|
||||
const authorId = `${baseId}-author`;
|
||||
|
||||
// Error handling functions
|
||||
const handleImageError = (error: unknown) => {
|
||||
logger.warn(
|
||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||
error,
|
||||
);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
|
||||
// Call error callback if provided
|
||||
if (onError) {
|
||||
onError({
|
||||
type: "image_load_error",
|
||||
@@ -118,11 +114,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoading(false);
|
||||
setImageError(false);
|
||||
};
|
||||
|
||||
// Validate required props
|
||||
if (variantProp === "statement") {
|
||||
if (!quote?.trim() || !quoteSecondary?.trim()) {
|
||||
@@ -166,9 +157,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
authorId={authorId}
|
||||
config={config}
|
||||
imageError={imageError}
|
||||
imageLoading={imageLoading}
|
||||
currentAvatarSrc={currentAvatarSrc}
|
||||
onImageLoad={handleImageLoad}
|
||||
onImageError={handleImageError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,8 +52,6 @@ export interface QuoteBlockViewProps {
|
||||
authorId: string;
|
||||
config: VariantConfig;
|
||||
imageError: boolean;
|
||||
imageLoading: boolean;
|
||||
currentAvatarSrc: string;
|
||||
onImageLoad: () => void;
|
||||
onImageError: (_error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ function QuoteBlockView({
|
||||
authorId,
|
||||
config,
|
||||
imageError,
|
||||
imageLoading,
|
||||
currentAvatarSrc,
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
}: QuoteBlockViewProps) {
|
||||
const t = useTranslation("quoteBlock");
|
||||
@@ -89,7 +87,6 @@ function QuoteBlockView({
|
||||
|
||||
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||
{/* Avatar with error handling */}
|
||||
<div className="relative">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
@@ -97,26 +94,12 @@ function QuoteBlockView({
|
||||
alt={avatarAlt}
|
||||
width={64}
|
||||
height={64}
|
||||
className={`filter sepia ${
|
||||
config.avatar
|
||||
} transition-opacity duration-300 ${
|
||||
imageLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
loading="lazy"
|
||||
className={`filter sepia ${config.avatar}`}
|
||||
loading="eager"
|
||||
priority
|
||||
onError={onImageError}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Loading state */}
|
||||
{imageLoading && !imageError && (
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error state - show initials */}
|
||||
{imageError && (
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import {
|
||||
fetchTemplates,
|
||||
isTemplatesFetchAborted,
|
||||
@@ -99,10 +99,8 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
logger.debug(`${slug} template clicked`);
|
||||
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
|
||||
// (local + server draft when sync) so stale state cannot break template apply.
|
||||
void (async () => {
|
||||
await prepareFreshCreateFlowEntry();
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
})();
|
||||
prepareFreshCreateFlowEntrySync();
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user