feat(ui): migrate public CV entry and recovery
This commit is contained in:
@@ -784,6 +784,13 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Restored version {version}.",
|
||||
cvEditorRestoreFailed: "Restore failed.",
|
||||
cvDashboardTitle: "CV Builder",
|
||||
publicCvTitle: "Public CV",
|
||||
publicCvDescription: "A read-only CV shared by its owner. Download the PDF to keep a copy.",
|
||||
publicCvDownloadPdf: "Download PDF",
|
||||
publicCvUnavailableTitle: "CV unavailable",
|
||||
publicCvUnavailableBody: "This CV isn’t available. The link may be wrong, or the owner has made it private.",
|
||||
publicCvLoadFailedTitle: "Could not load CV",
|
||||
publicCvLoadFailedBody: "This CV can’t be loaded right now. Please try again in a moment.",
|
||||
cvDashboardLibraryTitle: "Your CVs",
|
||||
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
|
||||
cvDashboardUntitled: "Untitled CV",
|
||||
@@ -3418,6 +3425,13 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
|
||||
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
|
||||
cvDashboardTitle: "CV-bygger",
|
||||
publicCvTitle: "Offentlig CV",
|
||||
publicCvDescription: "En CV som eieren har delt. Du kan lese den og laste ned en kopi som PDF.",
|
||||
publicCvDownloadPdf: "Last ned PDF",
|
||||
publicCvUnavailableTitle: "CV-en er utilgjengelig",
|
||||
publicCvUnavailableBody: "Denne CV-en er ikke tilgjengelig. Lenken kan være feil, eller eieren kan ha gjort CV-en privat.",
|
||||
publicCvLoadFailedTitle: "Kunne ikke laste CV",
|
||||
publicCvLoadFailedBody: "CV-en kan ikke lastes akkurat nå. Prøv igjen om litt.",
|
||||
cvDashboardLibraryTitle: "Dine CV-er",
|
||||
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
|
||||
cvDashboardUntitled: "CV uten tittel",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from './api';
|
||||
import PublicCvPage from './views/PublicCvPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -14,24 +15,254 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalTitle = document.title;
|
||||
|
||||
function RouteControls() {
|
||||
const navigate = useNavigate();
|
||||
return <><button onClick={() => navigate('/cv/slug-a')}>Open A</button><button onClick={() => navigate('/cv/slug-b')}>Open B</button></>;
|
||||
}
|
||||
|
||||
function renderPage(path = '/cv/public-slug') {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<RouteControls />
|
||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function switchLanguage(language: 'en' | 'nb') {
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockReset();
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
document.title = originalTitle;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
document.title = originalTitle;
|
||||
});
|
||||
|
||||
test('public CV exposes the rendered CV and PDF download', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/cv/public-slug']}>
|
||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const frame = await screen.findByTitle('Public CV');
|
||||
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
|
||||
expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
|
||||
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
|
||||
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
||||
});
|
||||
|
||||
test('a public slug containing path and URL delimiters stays one encoded API and PDF path segment', async () => {
|
||||
const slug = 'owner/document?mode=preview#section%';
|
||||
const encodedSlug = encodeURIComponent(slug);
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
renderPage(`/cv/${encodedSlug}`);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith(`/public-cv/${encodedSlug}`);
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Encoded slug document</p>', name: 'Candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Encoded slug document</p>');
|
||||
expect(screen.getByTitle('Public CV')).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', `/api/public-cv/${encodedSlug}/pdf`);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a held public CV read has a named page and loading announcement without a document or download', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
renderPage();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Current public document</p>', name: 'Ada' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current public document</p>');
|
||||
});
|
||||
|
||||
test('a revoked or missing public slug shows an unavailable state without retry or download', async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } });
|
||||
renderPage();
|
||||
expect(await screen.findByText('CV unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/link|owner|private/i);
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a transient failure retries only when requested and keeps focus on the page heading during its held read', async () => {
|
||||
const retry = deferred<any>();
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockImplementationOnce(() => retry.promise);
|
||||
renderPage();
|
||||
expect(await screen.findByText('Could not load CV')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
||||
retryButton.focus();
|
||||
fireEvent.click(retryButton);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toHaveFocus();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
await act(async () => { retry.resolve({ data: { html: '<p>Recovered CV</p>', name: 'Recovered candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Recovered CV</p>');
|
||||
expect(screen.queryByText('Could not load CV')).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('changing public slugs immediately hides the previous document, name and PDF and resets its measured height', async () => {
|
||||
const second = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate A</p>', name: 'Candidate A' } } as any).mockImplementationOnce(() => second.promise);
|
||||
renderPage('/cv/slug-a');
|
||||
const firstFrame = await screen.findByTitle('Public CV') as HTMLIFrameElement;
|
||||
Object.defineProperty(firstFrame.contentDocument!.documentElement, 'scrollHeight', { configurable: true, value: 3000 });
|
||||
fireEvent.load(firstFrame);
|
||||
expect(firstFrame.style.height).toBe('3000px');
|
||||
expect(document.title).toBe('Candidate A — CV');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(document.title).not.toContain('Candidate A');
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
await act(async () => { second.resolve({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } }); });
|
||||
const secondFrame = await screen.findByTitle('Public CV');
|
||||
expect(secondFrame).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(Number.parseFloat(secondFrame.style.height)).toBeCloseTo((297 / 25.4) * 96);
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
||||
expect(document.title).toBe('Candidate B — CV');
|
||||
});
|
||||
|
||||
test('a failed public slug cannot hide a subsequently loaded different public CV', async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
||||
renderPage('/cv/slug-a');
|
||||
await screen.findByText(/link.*wrong|wrong.*link/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
||||
});
|
||||
|
||||
test.each(['success', 'failure'])('a late %s from the previous slug cannot replace the current CV or its title', async (outcome) => {
|
||||
const previous = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
||||
renderPage('/cv/slug-a');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
await act(async () => {
|
||||
if (outcome === 'success') previous.resolve({ data: { html: '<p>Late candidate A</p>', name: 'Late candidate A' } });
|
||||
else previous.reject({ response: { status: 404 } });
|
||||
});
|
||||
expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(document.title).toBe('Candidate B — CV');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('language changes translate loading, failure and loaded chrome without refetching anonymous CV content', async () => {
|
||||
const first = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '<p>Untranslated candidate facts</p>', name: 'Candidate' } } as any);
|
||||
renderPage();
|
||||
switchLanguage('nb');
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Offentlig CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Laster/i);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { first.reject({ response: { status: 500 } }); });
|
||||
expect(await screen.findByText('Kunne ikke laste CV')).toBeInTheDocument();
|
||||
switchLanguage('en');
|
||||
expect(screen.getByText('Could not load CV')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
||||
switchLanguage('nb');
|
||||
expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
||||
expect(screen.getByRole('link', { name: 'Last ned PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('the public page removes only its own noindex metadata and ignores a response after unmount', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
const unrelated = document.createElement('meta');
|
||||
unrelated.name = 'robots';
|
||||
unrelated.content = 'index, follow';
|
||||
document.head.appendChild(unrelated);
|
||||
try {
|
||||
const page = renderPage();
|
||||
const owned = Array.from(document.head.querySelectorAll('meta[name="robots"]')).find((item) => item !== unrelated)!;
|
||||
expect(owned).toHaveAttribute('content', 'noindex, nofollow');
|
||||
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
||||
page.unmount();
|
||||
expect(owned).not.toBeInTheDocument();
|
||||
expect(unrelated).toBeInTheDocument();
|
||||
document.title = 'Destination after unmount';
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Late response</p>', name: 'Late candidate' } }); });
|
||||
expect(document.title).toBe('Destination after unmount');
|
||||
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
||||
} finally { unrelated.remove(); }
|
||||
});
|
||||
|
||||
test('leaving a loaded public CV restores the prior page title when its candidate title is still current', async () => {
|
||||
document.title = 'Jobs dashboard — Jobbjakt';
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
||||
const page = renderPage();
|
||||
await screen.findByTitle('Public CV');
|
||||
expect(document.title).toBe('Candidate identity — CV');
|
||||
page.unmount();
|
||||
expect(document.title).toBe('Jobs dashboard — Jobbjakt');
|
||||
});
|
||||
|
||||
test('leaving a loaded public CV preserves a page title already replaced by another owner', async () => {
|
||||
document.title = 'Jobs dashboard — Jobbjakt';
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
||||
const page = renderPage();
|
||||
await screen.findByTitle('Public CV');
|
||||
expect(document.title).toBe('Candidate identity — CV');
|
||||
document.title = 'Destination page — Jobbjakt';
|
||||
page.unmount();
|
||||
expect(document.title).toBe('Destination page — Jobbjakt');
|
||||
});
|
||||
|
||||
test('returning from A through B to A ignores both earlier held reads until the current A response arrives', async () => {
|
||||
const originalA = deferred<any>();
|
||||
const previousB = deferred<any>();
|
||||
const currentA = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => originalA.promise)
|
||||
.mockImplementationOnce(() => previousB.promise)
|
||||
.mockImplementationOnce(() => currentA.promise);
|
||||
renderPage('/cv/slug-a');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open A' }));
|
||||
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/public-cv/slug-a', '/public-cv/slug-b', '/public-cv/slug-a']);
|
||||
await act(async () => { originalA.resolve({ data: { html: '<p>Stale original A</p>', name: 'Stale original A' } }); });
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(document.title).not.toContain('Stale original A');
|
||||
await act(async () => { previousB.reject({ response: { status: 404 } }); });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
await act(async () => { currentA.resolve({ data: { html: '<p>Current A document</p>', name: 'Current A candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current A document</p>');
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-a/pdf');
|
||||
expect(document.title).toBe('Current A candidate — CV');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -1,43 +1,78 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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);
|
||||
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
|
||||
// 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;
|
||||
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
|
||||
.then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
|
||||
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.
|
||||
setError(err?.response?.status === 404
|
||||
? "This CV isn’t available. The link may be wrong, or the owner has made it private."
|
||||
: "This CV can’t be loaded right now. Please try again in a moment.");
|
||||
});
|
||||
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]);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
document.head.removeChild(meta);
|
||||
};
|
||||
}, [slug]);
|
||||
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)));
|
||||
@@ -46,41 +81,38 @@ export default function PublicCvPage() {
|
||||
return () => window.removeEventListener("resize", updateScale);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (html === null) {
|
||||
return (
|
||||
<div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
|
||||
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
|
||||
Download PDF
|
||||
</a>
|
||||
<div
|
||||
<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="Public CV"
|
||||
srcDoc={html}
|
||||
title={title}
|
||||
srcDoc={current.html}
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={(event) => {
|
||||
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
|
||||
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
|
||||
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>
|
||||
</div>
|
||||
</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user