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.
This commit is contained in:
1 parent
b2f3899dc2
commit
dfcadc71fa
77 files changed
+6944
-2419
No files matched your search
@@ -0,0 +1,427 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user