CI / verify (push) Failing after 5m29s
Estado do disco em 14/10/2026, sem filtrar. Inclui as copias .sync-conflict-* do Syncthing de 07/10 e os 15 arquivos que o Syncthing apagou; todo esse conteudo ja existia em outros commits. So havia fora do Git: AGENTS.md, CLAUDE.md e a alteracao em data/project-health.json.
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>
|
|
);
|
|
}
|