119 lines
5.4 KiB
TypeScript
119 lines
5.4 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Box, Button } from "@mui/material";
|
|
|
|
import { api } from "../api";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { PageHeader } from "../layout/PageLayout";
|
|
import ViewStateNotice from "../components/ViewStateNotice";
|
|
|
|
const A4_WIDTH_PX = (210 / 25.4) * 96;
|
|
const A4_HEIGHT_PX = (297 / 25.4) * 96;
|
|
|
|
type PublicCvRead = {
|
|
slug?: string;
|
|
html?: string;
|
|
name?: string;
|
|
error?: "missing" | "transient";
|
|
loading: boolean;
|
|
height: number;
|
|
};
|
|
|
|
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
|
|
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
|
|
export default function PublicCvPage() {
|
|
const { slug } = useParams();
|
|
const { t } = useI18n();
|
|
const pdfUrl = `${api.defaults.baseURL}/public-cv/${encodeURIComponent(slug ?? "")}/pdf`;
|
|
const [read, setRead] = useState<PublicCvRead>({ slug, loading: true, height: A4_HEIGHT_PX });
|
|
const [retry, setRetry] = useState(0);
|
|
const pending = useRef(false);
|
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
const previousTitle = useRef("");
|
|
const ownedTitle = useRef<string | null>(null);
|
|
const [frameScale, setFrameScale] = useState(1);
|
|
// Hide the old document on the first render of a new route, before its effect runs.
|
|
const current = read.slug === slug ? read : null;
|
|
const loading = !current || current.loading;
|
|
const frameHeight = current?.height ?? A4_HEIGHT_PX;
|
|
const title = t("publicCvTitle");
|
|
const unavailable = current?.error === "missing";
|
|
const errorTitle = t(unavailable ? "publicCvUnavailableTitle" : "publicCvLoadFailedTitle");
|
|
|
|
useEffect(() => {
|
|
const originalTitle = document.title;
|
|
previousTitle.current = originalTitle;
|
|
const meta = document.createElement("meta");
|
|
meta.name = "robots";
|
|
meta.content = "noindex, nofollow";
|
|
document.head.appendChild(meta);
|
|
return () => {
|
|
meta.remove();
|
|
if (document.title === ownedTitle.current) document.title = originalTitle;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
pending.current = true;
|
|
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
|
|
api.get<{ html: string; name: string }>(`/public-cv/${encodeURIComponent(slug ?? "")}`)
|
|
.then((r) => { if (alive) setRead({ slug, html: r.data.html, name: r.data.name, loading: false, height: A4_HEIGHT_PX }); })
|
|
.catch((err) => {
|
|
if (!alive) return;
|
|
// 404 = unknown slug or the owner made it private again. Anything else is a transient error.
|
|
setRead({ slug, error: err?.response?.status === 404 ? "missing" : "transient", loading: false, height: A4_HEIGHT_PX });
|
|
})
|
|
.finally(() => { if (alive) pending.current = false; });
|
|
return () => { alive = false; };
|
|
}, [slug, retry]);
|
|
|
|
useEffect(() => {
|
|
const nextTitle = current?.name ? `${current.name} — CV` : previousTitle.current;
|
|
document.title = nextTitle;
|
|
ownedTitle.current = nextTitle;
|
|
}, [current?.name]);
|
|
|
|
useEffect(() => {
|
|
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
|
|
updateScale();
|
|
window.addEventListener("resize", updateScale);
|
|
return () => window.removeEventListener("resize", updateScale);
|
|
}, []);
|
|
|
|
return (
|
|
<Box component="main" sx={{ minHeight: "100vh", bgcolor: "background.default", color: "text.primary", py: 3, px: "16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 2, minWidth: 0 }}>
|
|
<Box sx={{ width: "100%", maxWidth: A4_WIDTH_PX, minWidth: 0 }}>
|
|
<PageHeader title={title} titleRef={headingRef} description={t("publicCvDescription")}
|
|
actions={current?.html !== undefined ? <Button component="a" variant="contained" href={pdfUrl} download>{t("publicCvDownloadPdf")}</Button> : undefined} />
|
|
<ViewStateNotice compact loading={loading} title={errorTitle}
|
|
error={current?.error ? { kind: "unavailable", message: errorTitle, retryable: !unavailable } : null}
|
|
description={t(unavailable ? "publicCvUnavailableBody" : "publicCvLoadFailedBody")}
|
|
onRetry={() => {
|
|
if (pending.current || loading) return;
|
|
pending.current = true;
|
|
headingRef.current?.focus();
|
|
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
|
|
setRetry((value) => value + 1);
|
|
}} />
|
|
</Box>
|
|
{current?.html !== undefined && <div
|
|
data-testid="public-cv-frame-container"
|
|
style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
|
|
>
|
|
<iframe
|
|
title={title}
|
|
srcDoc={current.html}
|
|
sandbox="allow-same-origin"
|
|
onLoad={(event) => {
|
|
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
|
|
setRead((previous) => previous.slug === slug && previous.html === current.html && !previous.loading
|
|
? { ...previous, height: Math.max(A4_HEIGHT_PX, documentHeight) } : previous);
|
|
}}
|
|
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
|
|
/>
|
|
</div>}
|
|
</Box>
|
|
);
|
|
}
|