CI / verify (push) Failing after 5m9s
Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import {
|
|
type Ref,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { NotebookProject } from "./notebookProjects";
|
|
import type { NotebookFrame } from "./notebookTimeline";
|
|
|
|
type CursorFrame = Pick<
|
|
NotebookFrame,
|
|
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
|
>;
|
|
export type NotebookBrowserHandle = {
|
|
updateFrame: (frame: CursorFrame) => void;
|
|
};
|
|
|
|
export type NotebookBrowserProps = {
|
|
projects: NotebookProject[];
|
|
activeIndex: number;
|
|
frame: CursorFrame;
|
|
ref?: Ref<NotebookBrowserHandle>;
|
|
onSelectTab: (index: number) => void;
|
|
interactive?: boolean;
|
|
};
|
|
|
|
function clampIndex(index: number, length: number): number {
|
|
if (length < 1) return -1;
|
|
return Math.min(Math.max(index, 0), length - 1);
|
|
}
|
|
|
|
function toDomId(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
}
|
|
|
|
export default function NotebookBrowser({
|
|
projects,
|
|
activeIndex,
|
|
frame,
|
|
onSelectTab,
|
|
interactive = true,
|
|
ref,
|
|
}: NotebookBrowserProps) {
|
|
const selectedIndex = clampIndex(activeIndex, projects.length);
|
|
const selectedProject =
|
|
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const cursorRef = useRef<SVGSVGElement>(null);
|
|
const cursorFrame = useRef(frame);
|
|
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
|
|
const browserSize = useRef({ width: 0, height: 0 });
|
|
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
|
|
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
|
|
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
const requestedSrc = selectedProject?.screenshotSrc;
|
|
const requestRef = useRef(requestedSrc);
|
|
const requestVersion = useRef(0);
|
|
const displayedRef = useRef(displayed);
|
|
const mounted = useRef(true);
|
|
useLayoutEffect(() => {
|
|
requestRef.current = requestedSrc;
|
|
requestVersion.current += 1;
|
|
setFailedSrc(null);
|
|
}, [requestedSrc]);
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
return () => {
|
|
mounted.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Only the immediate neighbours are fetched; the browser cache handles reuse.
|
|
useEffect(() => {
|
|
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
|
|
.filter((project): project is NotebookProject => Boolean(project))
|
|
.map((project) => {
|
|
const image = new window.Image();
|
|
image.decoding = "async";
|
|
image.src = project.screenshotSrc;
|
|
return image;
|
|
});
|
|
return () => {
|
|
images.forEach((image) => {
|
|
image.removeAttribute("src");
|
|
});
|
|
};
|
|
}, [projects, selectedIndex]);
|
|
|
|
const updateFrame = useCallback((next: CursorFrame) => {
|
|
cursorFrame.current = next;
|
|
const cursor = cursorRef.current;
|
|
const points = tabPoints.current;
|
|
if (!cursor || points.length === 0) return;
|
|
const position = Math.max(
|
|
0,
|
|
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
|
|
);
|
|
const left = Math.floor(position);
|
|
const right = Math.min(left + 1, points.length - 1);
|
|
const blend = position - left;
|
|
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
|
|
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
|
|
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
|
|
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
|
|
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
|
|
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
|
|
cursor.style.opacity = next.cursorVisible ? "1" : "0";
|
|
cursor.classList.toggle("is-clicking", next.cursorClicking);
|
|
}, []);
|
|
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
|
|
useLayoutEffect(() => {
|
|
const root = rootRef.current;
|
|
if (!root) return undefined;
|
|
const measure = () => {
|
|
browserSize.current = {
|
|
width: root.clientWidth,
|
|
height: root.clientHeight,
|
|
};
|
|
const points = [];
|
|
for (const project of projects) {
|
|
const tab: HTMLButtonElement | null =
|
|
root.querySelector<HTMLButtonElement>(
|
|
`#notebook-tab-${toDomId(project.id)}`,
|
|
);
|
|
if (!tab) continue;
|
|
let x = tab.offsetWidth / 2;
|
|
let y = tab.offsetHeight / 2;
|
|
let element: HTMLElement | null = tab;
|
|
while (element && element !== root) {
|
|
x += element.offsetLeft;
|
|
y += element.offsetTop;
|
|
element = element.offsetParent as HTMLElement | null;
|
|
}
|
|
points.push({ x, y });
|
|
}
|
|
tabPoints.current = points;
|
|
updateFrame(cursorFrame.current);
|
|
};
|
|
measure();
|
|
if (!("ResizeObserver" in window)) return undefined;
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(root);
|
|
return () => observer.disconnect();
|
|
}, [projects, updateFrame]);
|
|
useLayoutEffect(() => {
|
|
updateFrame(frame);
|
|
}, [frame, updateFrame]);
|
|
|
|
const revealImage = useCallback(
|
|
async (image: HTMLImageElement, project: NotebookProject) => {
|
|
const version = requestVersion.current;
|
|
try {
|
|
if (typeof image.decode === "function") await image.decode();
|
|
} catch {
|
|
if (
|
|
mounted.current &&
|
|
version === requestVersion.current &&
|
|
requestRef.current === project.screenshotSrc
|
|
) {
|
|
setFailedSrc(project.screenshotSrc);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
!mounted.current ||
|
|
version !== requestVersion.current ||
|
|
requestRef.current !== project.screenshotSrc
|
|
)
|
|
return;
|
|
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
|
|
setOutgoing(
|
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
? null
|
|
: displayedRef.current,
|
|
);
|
|
displayedRef.current = project;
|
|
setDisplayed(project);
|
|
setFailedSrc(null);
|
|
},
|
|
[],
|
|
);
|
|
useLayoutEffect(() => {
|
|
if (!selectedProject) return;
|
|
// A quick reversal can select the still-mounted outgoing image: no new load event.
|
|
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
|
|
.href;
|
|
const image = Array.from(
|
|
rootRef.current?.querySelectorAll("img") ?? [],
|
|
).find((item) => item.src === source);
|
|
if (image?.complete && image.naturalWidth > 0) {
|
|
void revealImage(image, selectedProject);
|
|
}
|
|
}, [selectedProject, revealImage]);
|
|
|
|
const imageProjects = [outgoing, displayed, selectedProject].filter(
|
|
(project, index, list): project is NotebookProject =>
|
|
Boolean(project) &&
|
|
list.findIndex(
|
|
(item) => item?.screenshotSrc === project?.screenshotSrc,
|
|
) === index,
|
|
);
|
|
const waiting = displayed?.screenshotSrc !== requestedSrc;
|
|
const interactionProps = interactive
|
|
? {}
|
|
: {
|
|
"aria-hidden": true,
|
|
inert: true,
|
|
};
|
|
|
|
function handleTabKeyDown(
|
|
event: React.KeyboardEvent<HTMLButtonElement>,
|
|
index: number,
|
|
) {
|
|
if (projects.length < 1) return;
|
|
|
|
let nextIndex: number | null = null;
|
|
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
nextIndex = Math.min(index + 1, projects.length - 1);
|
|
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
nextIndex = Math.max(index - 1, 0);
|
|
} else if (event.key === "Home") {
|
|
nextIndex = 0;
|
|
} else if (event.key === "End") {
|
|
nextIndex = projects.length - 1;
|
|
}
|
|
|
|
if (nextIndex === null) return;
|
|
event.preventDefault();
|
|
event.currentTarget.parentElement
|
|
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
|
[nextIndex]?.focus({ preventScroll: true });
|
|
onSelectTab(nextIndex);
|
|
}
|
|
|
|
if (!selectedProject) {
|
|
return (
|
|
<div
|
|
{...interactionProps}
|
|
className="notebook-browser"
|
|
data-testid="notebook-browser"
|
|
data-empty
|
|
>
|
|
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedId = toDomId(selectedProject.id);
|
|
const panelId = `notebook-panel-${selectedId}`;
|
|
|
|
return (
|
|
<div
|
|
{...interactionProps}
|
|
className="notebook-browser"
|
|
data-testid="notebook-browser"
|
|
ref={rootRef}
|
|
>
|
|
<div className="notebook-browser-bar" aria-hidden="true">
|
|
<span className="notebook-browser-dot" />
|
|
<span className="notebook-browser-dot" />
|
|
<span className="notebook-browser-dot" />
|
|
<span className="notebook-browser-address">
|
|
evandro.dev.br / projetos
|
|
</span>
|
|
</div>
|
|
|
|
<div className="notebook-browser-body">
|
|
<div className="notebook-browser-heading">
|
|
<span className="notebook-browser-eyebrow">Projetos em produção</span>
|
|
<span className="notebook-browser-counter">
|
|
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
|
|
{String(projects.length).padStart(2, "0")}
|
|
</span>
|
|
</div>
|
|
<output className="sr-only" aria-live="polite">
|
|
Projeto ativo: {selectedProject.title}
|
|
</output>
|
|
|
|
<div
|
|
className="notebook-tablist"
|
|
role="tablist"
|
|
aria-label="Projetos em produção"
|
|
>
|
|
{projects.map((project, index) => {
|
|
const tabId = `notebook-tab-${toDomId(project.id)}`;
|
|
const isSelected = index === selectedIndex;
|
|
|
|
return (
|
|
<button
|
|
aria-controls={panelId}
|
|
aria-selected={isSelected}
|
|
className="notebook-tab"
|
|
id={tabId}
|
|
key={project.id}
|
|
onClick={() => onSelectTab(index)}
|
|
onKeyDown={(event) => handleTabKeyDown(event, index)}
|
|
role="tab"
|
|
tabIndex={isSelected ? 0 : -1}
|
|
type="button"
|
|
>
|
|
<span className="notebook-tab-number">
|
|
{String(index + 1).padStart(2, "0")}
|
|
</span>
|
|
<span className="notebook-tab-title">{project.title}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
aria-labelledby={`notebook-tab-${selectedId}`}
|
|
className="notebook-panel"
|
|
id={panelId}
|
|
role="tabpanel"
|
|
>
|
|
<div className="notebook-panel-copy">
|
|
<h3>{selectedProject.title}</h3>
|
|
<p>{selectedProject.subtitle}</p>
|
|
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
|
|
{selectedProject.technologies.slice(0, 4).map((technology) => (
|
|
<li key={technology}>{technology}</li>
|
|
))}
|
|
</ul>
|
|
{selectedProject.demoAvailability === "unavailable" ? (
|
|
<span aria-disabled="true">
|
|
Demo indisponível na última verificação
|
|
</span>
|
|
) : selectedProject.demoAvailability === "none" ||
|
|
!selectedProject.demoUrl ? null : (
|
|
<a
|
|
href={selectedProject.demoUrl}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
Abrir projeto <span aria-hidden="true">↗</span>
|
|
{selectedProject.demoAvailability === "unknown" ? (
|
|
<span className="sr-only">
|
|
{" "}
|
|
Disponibilidade não verificada
|
|
</span>
|
|
) : null}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="notebook-screenshot-wrap"
|
|
aria-busy={waiting && failedSrc !== requestedSrc}
|
|
>
|
|
{imageProjects.map((project) => {
|
|
const current =
|
|
project.screenshotSrc === displayed?.screenshotSrc;
|
|
const previous =
|
|
project.screenshotSrc === outgoing?.screenshotSrc;
|
|
return (
|
|
<Image
|
|
key={project.screenshotSrc}
|
|
alt={current ? `Captura de tela de ${project.title}` : ""}
|
|
aria-hidden={!current}
|
|
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
|
|
width={project.screenshotWidth}
|
|
height={project.screenshotHeight}
|
|
loading="eager"
|
|
sizes="(min-width: 1025px) 800px, 100vw"
|
|
src={project.screenshotSrc}
|
|
unoptimized
|
|
onLoad={(event) => {
|
|
void revealImage(event.currentTarget, project);
|
|
}}
|
|
onError={() => {
|
|
if (project.screenshotSrc === requestRef.current)
|
|
setFailedSrc(project.screenshotSrc);
|
|
}}
|
|
onAnimationEnd={() => {
|
|
if (current) setOutgoing(null);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
{waiting ? (
|
|
<p className="notebook-screenshot-status" aria-live="polite">
|
|
{failedSrc === requestedSrc
|
|
? `Não foi possível carregar a captura de ${selectedProject.title}.`
|
|
: `Carregando captura de ${selectedProject.title}…`}
|
|
{displayed
|
|
? ` Exibindo ${displayed.title} enquanto isso.`
|
|
: null}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<svg
|
|
aria-hidden="true"
|
|
className="notebook-cursor"
|
|
ref={cursorRef}
|
|
viewBox="0 0 28 36"
|
|
width="28"
|
|
height="36"
|
|
>
|
|
<title>Cursor decorativo</title>
|
|
<path
|
|
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
|
|
fill="#0a0a0a"
|
|
stroke="#f4f2ed"
|
|
strokeWidth="1.8"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<circle
|
|
className="notebook-cursor-pulse"
|
|
cx="2"
|
|
cy="2"
|
|
r="8"
|
|
fill="none"
|
|
stroke="#ccff00"
|
|
strokeWidth="2"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|