feat: harden portfolio and add SEO foundations
This commit is contained in:
1 parent
b079afdd73
commit
62bcfbd8fa
121 files changed
+7769
-10469
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const frame: NotebookBrowserProps["frame"] = {
|
||||
cursorVisible: true,
|
||||
cursorX: 0.4,
|
||||
@@ -113,15 +113,65 @@ describe("NotebookBrowser", () => {
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
|
||||
it("exposes a real deployment link with safe new-tab attributes", () => {
|
||||
renderBrowser();
|
||||
it("keeps an unknown demo link and announces its state", () => {
|
||||
const unknownProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unknown" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unknownProjects });
|
||||
|
||||
const link = screen.getByRole("link", { name: /abrir projeto/i });
|
||||
const link = screen.getByRole("link", {
|
||||
name: /abrir projeto.*disponibilidade não verificada/i,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", projects[0].demoUrl);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("renders an unavailable demo as non-navigable text", () => {
|
||||
const unavailableProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unavailableProjects });
|
||||
|
||||
const unavailable = screen.getByText(
|
||||
"Demo indisponível na última verificação",
|
||||
);
|
||||
expect(unavailable).toHaveAttribute("aria-disabled", "true");
|
||||
expect(unavailable.closest("a")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /abrir projeto/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no demo action for a project without a demo", () => {
|
||||
const noDemoProjects = [
|
||||
{
|
||||
...projects[0],
|
||||
demoUrl: null,
|
||||
demoAvailability: "none",
|
||||
demoCheckedAt: null,
|
||||
},
|
||||
] as unknown as NotebookBrowserProps["projects"];
|
||||
renderBrowser({ projects: noDemoProjects });
|
||||
|
||||
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Demo indisponível na última verificação"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the animated cursor decorative and never embeds an iframe", () => {
|
||||
const { container } = renderBrowser({
|
||||
frame: { ...frame, cursorClicking: true },
|
||||
|
||||
@@ -176,13 +176,26 @@ export default function NotebookBrowser({
|
||||
<li key={technology}>{technology}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={selectedProject.demoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Abrir projeto <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
{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">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
|
||||
describe("NotebookFallback", () => {
|
||||
it("keeps project content and controls in normal document flow", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
@@ -30,7 +30,7 @@ jest.mock("@react-three/drei", () => ({
|
||||
RoundedBox: () => <div data-testid="mock-roundedbox" />,
|
||||
}));
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
isVisible: true,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, {
|
||||
canUseNotebook3D,
|
||||
getNotebookRunwayVh,
|
||||
getNotebookSeekProgress,
|
||||
} from "./NotebookStory";
|
||||
|
||||
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
|
||||
));
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
return function DynamicNotebookStage() {
|
||||
return <div data-testid="notebook-stage" />;
|
||||
return function DynamicNotebookStage(props: { isVisible: boolean }) {
|
||||
return mockNotebookStage(props);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,7 +30,23 @@ jest.mock("gsap", () => ({
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
const projects = portfolio.projects;
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
const mockObserve = jest.fn();
|
||||
const mockDisconnect = jest.fn();
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: hidden,
|
||||
});
|
||||
}
|
||||
|
||||
function setSectionIntersects(isIntersecting: boolean) {
|
||||
const entry = { isIntersecting } as IntersectionObserverEntry;
|
||||
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
|
||||
}
|
||||
|
||||
function mockMatchMedia({
|
||||
reduced = false,
|
||||
@@ -62,6 +82,26 @@ describe("NotebookStory", () => {
|
||||
configurable: true,
|
||||
value: 1280,
|
||||
});
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
mockObserve.mockReset();
|
||||
mockDisconnect.mockReset();
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = mockDisconnect;
|
||||
observe = mockObserve;
|
||||
takeRecords = () => [];
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -99,6 +139,43 @@ describe("NotebookStory", () => {
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toHaveAttribute("data-notebook-runway-vh", "520");
|
||||
});
|
||||
|
||||
it("stays paused when the tab returns while the section is offscreen", async () => {
|
||||
const addEventListener = jest.spyOn(document, "addEventListener");
|
||||
const removeEventListener = jest.spyOn(document, "removeEventListener");
|
||||
const { unmount } = render(<NotebookStory projects={projects} />);
|
||||
|
||||
const stage = await screen.findByTestId("notebook-stage");
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
|
||||
setSectionIntersects(true);
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "true"),
|
||||
);
|
||||
|
||||
setDocumentHidden(true);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false"),
|
||||
);
|
||||
|
||||
setSectionIntersects(false);
|
||||
setDocumentHidden(false);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
unmount();
|
||||
expect(
|
||||
removeEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame } from "./notebookTimeline";
|
||||
@@ -18,7 +18,7 @@ const BROWSE_END = 0.78;
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProject[];
|
||||
projects: PortfolioProjectView[];
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
@@ -64,11 +64,17 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
const progressRef = useRef(0);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [sectionIntersects, setSectionIntersects] = useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => typeof document === "undefined" || !document.hidden,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
const updateEngine = () => {
|
||||
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
||||
const nextEngine =
|
||||
notebookProjects.length > 0 && canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
@@ -91,6 +97,15 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
};
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsPageVisible(!document.hidden);
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section || !("IntersectionObserver" in window)) {
|
||||
@@ -98,7 +113,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsVisible(entry?.isIntersecting ?? false),
|
||||
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(section);
|
||||
@@ -246,7 +261,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
</div>
|
||||
<NotebookStage3D
|
||||
activeIndex={activeIndex}
|
||||
isVisible={isVisible}
|
||||
isVisible={sectionIntersects && isPageVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
|
||||
*
|
||||
* Contrato (declarativo, via atributos data-*):
|
||||
* [data-reel="left|right|top|bottom|zoom|fade"]
|
||||
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
|
||||
* [data-reel-stagger="left|right|top|zoom|fade|bottom"]
|
||||
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
|
||||
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
||||
* data-reel-distance="64" (opcional: px de deslocamento)
|
||||
*
|
||||
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
|
||||
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
|
||||
*
|
||||
* Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um
|
||||
* atributo data-reel-init no DOM) e o primeiro scan roda depois que a
|
||||
* hidratação assentou (double-rAF + debounce) — MutationObserver dispara em
|
||||
* microtask e pegaria nós ainda em hidratação, produzindo mismatch de
|
||||
* atributos no overlay do Next.
|
||||
*/
|
||||
|
||||
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
|
||||
|
||||
const DISTANCE = 64;
|
||||
|
||||
function fromVars(dir: Direction, dist: number) {
|
||||
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
|
||||
if (dir === "left") return { ...base, x: -dist };
|
||||
if (dir === "right") return { ...base, x: dist };
|
||||
if (dir === "top") return { ...base, y: -dist * 0.75 };
|
||||
if (dir === "bottom") return { ...base, y: dist };
|
||||
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
|
||||
return { ...base, scale: 1.02 };
|
||||
}
|
||||
|
||||
const TO_VARS = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
ease: "none",
|
||||
};
|
||||
|
||||
export default function ReelDirector() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return undefined;
|
||||
|
||||
let teardown = () => {};
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
let timer = 0;
|
||||
|
||||
const boot = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const done = new WeakSet<Element>();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scan = () => {
|
||||
if (cancelled) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel]",
|
||||
)) {
|
||||
if (done.has(el)) continue;
|
||||
done.add(el);
|
||||
const dir = (el.dataset.reel || "bottom") as Direction;
|
||||
const dist = Number(el.dataset.reelDistance || DISTANCE);
|
||||
gsap.fromTo(el, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: el.dataset.reelStart || "top 88%",
|
||||
end: el.dataset.reelEnd || "top 26%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const container of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel-stagger]",
|
||||
)) {
|
||||
if (done.has(container)) continue;
|
||||
done.add(container);
|
||||
const dir = (container.dataset.reelStagger ||
|
||||
"bottom") as Direction;
|
||||
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) continue;
|
||||
gsap.fromTo(children, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
stagger: { each: 0.09 },
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: container.dataset.reelStart || "top 85%",
|
||||
end: container.dataset.reelEnd || "top 25%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
|
||||
let pending = 0;
|
||||
const scheduleScan = () => {
|
||||
window.clearTimeout(pending);
|
||||
pending = window.setTimeout(scan, 120);
|
||||
};
|
||||
|
||||
// espera a hidratação assentar antes de tocar o DOM
|
||||
raf1 = window.requestAnimationFrame(() => {
|
||||
raf2 = window.requestAnimationFrame(() => {
|
||||
timer = window.setTimeout(scan, 250);
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(scheduleScan);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
teardown = () => {
|
||||
window.clearTimeout(pending);
|
||||
observer.disconnect();
|
||||
ctx.revert();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
void boot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
window.clearTimeout(timer);
|
||||
teardown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type PortfolioProject, portfolio } from "@/lib/portfolio-data";
|
||||
import {
|
||||
getFeaturedProjects,
|
||||
type PortfolioProjectView,
|
||||
} from "@/lib/portfolio-data";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projectsFixture = portfolio.projects as PortfolioProject[];
|
||||
const projectsFixture = getFeaturedProjects(null);
|
||||
|
||||
describe("selectNotebookProjects", () => {
|
||||
it("returns the four ordered projects with local screenshots", () => {
|
||||
@@ -31,26 +34,54 @@ describe("selectNotebookProjects", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a project without an approved demo URL", () => {
|
||||
const projectsWithoutDemoUrl = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan" ? { ...project, demoUrl: null } : project,
|
||||
) as unknown as PortfolioProject[];
|
||||
const result = selectNotebookProjects(projectsWithoutDemoUrl);
|
||||
it("omits a project with an unapproved demo URL", () => {
|
||||
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? { ...project, demoUrl: "javascript:alert(1)" }
|
||||
: project,
|
||||
);
|
||||
const result = selectNotebookProjects(projectsWithUnsafeUrl);
|
||||
|
||||
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
|
||||
});
|
||||
|
||||
it("copies only the browser contract fields", () => {
|
||||
const [project] = selectNotebookProjects(projectsFixture);
|
||||
it("keeps a project without a demo so its local evidence remains visible", () => {
|
||||
const projectsWithoutDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoUrl: null,
|
||||
demoAvailability: "none" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
) as unknown as PortfolioProjectView[];
|
||||
|
||||
expect(project).toEqual({
|
||||
id: "nutriscan",
|
||||
title: "NutriScan",
|
||||
subtitle: "Análise Nutricional Inteligente",
|
||||
demoUrl: "https://nutriscan.evandro.dev.br",
|
||||
technologies: expect.arrayContaining(["Next.js 14", "TypeScript"]),
|
||||
screenshotSrc: "/assets/prints/print-nutriscan.png",
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithoutDemo).some(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unavailable project when its local evidence and URL exist", () => {
|
||||
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithUnavailableDemo).find(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toMatchObject({
|
||||
demoAvailability: "unavailable",
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
});
|
||||
expect(project).not.toHaveProperty("description");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
|
||||
export type NotebookProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
demoUrl: string;
|
||||
demoUrl: PortfolioProjectView["demoUrl"];
|
||||
demoAvailability: PortfolioProjectView["demoAvailability"];
|
||||
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
|
||||
technologies: string[];
|
||||
screenshotSrc: string;
|
||||
};
|
||||
@@ -27,19 +29,22 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
|
||||
};
|
||||
|
||||
function isApprovedDemoUrl(
|
||||
value: PortfolioProject["demoUrl"],
|
||||
value: PortfolioProjectView["demoUrl"],
|
||||
): value is string {
|
||||
return typeof value === "string" && /^https?:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function selectNotebookProjects(
|
||||
projects: readonly PortfolioProject[],
|
||||
projects: readonly PortfolioProjectView[],
|
||||
): NotebookProject[] {
|
||||
return NOTEBOOK_ORDER.flatMap((id) => {
|
||||
const project = projects.find((candidate) => candidate.id === id);
|
||||
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
|
||||
|
||||
if (!project || !isApprovedDemoUrl(project.demoUrl)) {
|
||||
if (
|
||||
!project ||
|
||||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -49,6 +54,8 @@ export function selectNotebookProjects(
|
||||
title: project.title,
|
||||
subtitle: project.subtitle,
|
||||
demoUrl: project.demoUrl,
|
||||
demoAvailability: project.demoAvailability,
|
||||
demoCheckedAt: project.demoCheckedAt,
|
||||
technologies: [...project.technologies],
|
||||
screenshotSrc,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user