249 lines
7.9 KiB
TypeScript
249 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { NotebookProject } from "./notebookProjects";
|
|
import type { NotebookFrame } from "./notebookTimeline";
|
|
|
|
export type NotebookBrowserProps = {
|
|
projects: NotebookProject[];
|
|
activeIndex: number;
|
|
frame: Pick<
|
|
NotebookFrame,
|
|
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
|
>;
|
|
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,
|
|
}: NotebookBrowserProps) {
|
|
const selectedIndex = clampIndex(activeIndex, projects.length);
|
|
const selectedProject =
|
|
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
|
const previousIndexRef = useRef(selectedIndex);
|
|
const [outgoingIndex, setOutgoingIndex] = useState<number | null>(null);
|
|
useEffect(() => {
|
|
const previousIndex = previousIndexRef.current;
|
|
if (previousIndex === selectedIndex) return undefined;
|
|
|
|
previousIndexRef.current = selectedIndex;
|
|
setOutgoingIndex(previousIndex >= 0 ? previousIndex : null);
|
|
const timeout = window.setTimeout(() => setOutgoingIndex(null), 180);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [selectedIndex]);
|
|
|
|
const outgoingProject =
|
|
outgoingIndex !== null ? projects[outgoingIndex] : undefined;
|
|
const nextProject =
|
|
selectedIndex >= 0 && projects.length > 1
|
|
? projects[(selectedIndex + 1) % projects.length]
|
|
: undefined;
|
|
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();
|
|
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"
|
|
>
|
|
<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"
|
|
data-cursor-clicking={
|
|
isSelected && frame.cursorClicking ? "true" : "false"
|
|
}
|
|
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">
|
|
{outgoingProject ? (
|
|
<Image
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="notebook-screenshot notebook-screenshot-outgoing"
|
|
fill
|
|
sizes="(min-width: 681px) 60vw, 100vw"
|
|
src={outgoingProject.screenshotSrc}
|
|
unoptimized
|
|
/>
|
|
) : null}
|
|
<Image
|
|
alt={`Captura de tela de ${selectedProject.title}`}
|
|
className="notebook-screenshot notebook-screenshot-current"
|
|
fill
|
|
sizes="(min-width: 681px) 60vw, 100vw"
|
|
src={selectedProject.screenshotSrc}
|
|
unoptimized
|
|
/>
|
|
{nextProject ? (
|
|
<Image
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="notebook-screenshot-preload"
|
|
fill
|
|
sizes="1px"
|
|
src={nextProject.screenshotSrc}
|
|
unoptimized
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{frame.cursorVisible ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`}
|
|
data-testid="notebook-cursor"
|
|
style={{
|
|
left: `${frame.cursorX * 100}%`,
|
|
top: `${frame.cursorY * 100}%`,
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|