mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): open a PDF or image original in a new browser tab
The document sheet gains an Open in new tab action for the types the browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report gets the browser's own zoom and search instead of the small preview. It opens the object URL the sheet already fetched through the app transport: a plain navigation to the serve route carries no record-session assertion and would be refused inside a shared record. Every other type keeps the download only, and the serve route's matrix now pins SVG and HTML to attachment as well.
This commit is contained in:
1 parent
fca1260b20
commit
12a8ab7c63
10 files changed
+95
-2
No files matched your search
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "Die Änderung konnte nicht gespeichert werden.",
|
||||
"deleteError": "Das Dokument konnte nicht gelöscht werden.",
|
||||
"download": "Herunterladen",
|
||||
"openInNewTab": "In neuem Tab öffnen",
|
||||
"delete": "Löschen",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel hinzufügen",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "Couldn't save the change.",
|
||||
"deleteError": "Couldn't delete the document.",
|
||||
"download": "Download",
|
||||
"openInNewTab": "Open in new tab",
|
||||
"delete": "Delete",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Add a title",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "No se pudo guardar el cambio.",
|
||||
"deleteError": "No se pudo eliminar el documento.",
|
||||
"download": "Descargar",
|
||||
"openInNewTab": "Abrir en una pestaña nueva",
|
||||
"delete": "Eliminar",
|
||||
"titleLabel": "Título",
|
||||
"titlePlaceholder": "Añadir un título",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "Impossible d'enregistrer la modification.",
|
||||
"deleteError": "Impossible de supprimer le document.",
|
||||
"download": "Télécharger",
|
||||
"openInNewTab": "Ouvrir dans un nouvel onglet",
|
||||
"delete": "Supprimer",
|
||||
"titleLabel": "Titre",
|
||||
"titlePlaceholder": "Ajouter un titre",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "Impossibile salvare la modifica.",
|
||||
"deleteError": "Impossibile eliminare il documento.",
|
||||
"download": "Scarica",
|
||||
"openInNewTab": "Apri in una nuova scheda",
|
||||
"delete": "Elimina",
|
||||
"titleLabel": "Titolo",
|
||||
"titlePlaceholder": "Aggiungi un titolo",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "변경 내용을 저장하지 못했어요.",
|
||||
"deleteError": "문서를 삭제하지 못했어요.",
|
||||
"download": "다운로드",
|
||||
"openInNewTab": "새 탭에서 열기",
|
||||
"delete": "삭제",
|
||||
"titleLabel": "제목",
|
||||
"titlePlaceholder": "제목 입력",
|
||||
|
||||
@@ -10858,6 +10858,7 @@
|
||||
"saveError": "Nie udało się zapisać zmiany.",
|
||||
"deleteError": "Nie udało się usunąć dokumentu.",
|
||||
"download": "Pobierz",
|
||||
"openInNewTab": "Otwórz w nowej karcie",
|
||||
"delete": "Usuń",
|
||||
"titleLabel": "Tytuł",
|
||||
"titlePlaceholder": "Dodaj tytuł",
|
||||
|
||||
@@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)",
|
||||
["image/heic", "photo.heic"],
|
||||
["application/xml", "export.xml"],
|
||||
["application/json", "export.json"],
|
||||
// Upload refuses markup outright; a row that somehow carries one must
|
||||
// still never render in-origin, where its script would run.
|
||||
["image/svg+xml", "drawing.svg"],
|
||||
["text/html", "page.html"],
|
||||
])(
|
||||
"Class B %s → attachment + octet-stream, never inline",
|
||||
async (mime, name) => {
|
||||
|
||||
@@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open in new tab" hands the browser the types it renders itself, so a long
|
||||
* report gets the browser's zoom and search. It must open the object URL the
|
||||
* sheet already holds (a bare navigation to the route carries no record-session
|
||||
* assertion and is refused once the session has been inside a shared record),
|
||||
* and it must never appear for a type outside the inline serving class.
|
||||
*/
|
||||
describe("the detail sheet's open-in-new-tab control", () => {
|
||||
const control = SHEET.slice(
|
||||
SHEET.indexOf("function DocumentOpenInTabButton("),
|
||||
SHEET.indexOf("/** Inline preview for Class A documents"),
|
||||
);
|
||||
|
||||
it("exists", () => {
|
||||
expect(control.length).toBeGreaterThan(0);
|
||||
expect(SHEET).toMatch(/<DocumentOpenInTabButton\s/);
|
||||
});
|
||||
|
||||
it("renders only for the inline serving class", () => {
|
||||
expect(SHEET).toMatch(
|
||||
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
|
||||
);
|
||||
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens the fetched object URL, never the route, without an opener", () => {
|
||||
expect(control).toContain('window.open(readyUrl, "_blank", "noopener")');
|
||||
expect(control).not.toContain("/api/documents");
|
||||
});
|
||||
|
||||
it("is disabled until the bytes are in hand, and when they failed", () => {
|
||||
expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/);
|
||||
expect(SHEET).toMatch(
|
||||
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the stored original in a new browser tab, for the types the browser
|
||||
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
|
||||
* long report gets the browser's own zoom and search.
|
||||
*
|
||||
* It opens the object URL the sheet already holds rather than the route: a
|
||||
* plain navigation carries no record-session assertion, so the fence would
|
||||
* refuse it once the session has been inside a shared record. The bytes are
|
||||
* already in hand, so the tab opens inside the click and no popup blocker
|
||||
* steps in. A type outside the inline class never gets the control.
|
||||
*/
|
||||
function DocumentOpenInTabButton({
|
||||
readyUrl,
|
||||
unavailable,
|
||||
}: {
|
||||
readyUrl: string | null;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
data-slot="document-open-in-tab"
|
||||
disabled={readyUrl === null || unavailable}
|
||||
aria-label={t("documents.detail.openInNewTab")}
|
||||
onClick={() => {
|
||||
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.openInNewTab")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline preview for Class A documents; skeleton until the blob paints. */
|
||||
function InlinePreview({
|
||||
src,
|
||||
@@ -759,8 +797,8 @@ export function DocumentDetailSheet({
|
||||
doc ? (
|
||||
// Delete sits bottom-LEFT, quieted to a text button: the
|
||||
// bottom-right slot reads as the primary/confirm action, and a
|
||||
// destructive control there is a footgun. Share + Download — the
|
||||
// safe, expected actions — keep the trailing edge. On phones their
|
||||
// destructive control there is a footgun. Share, open in a new tab and
|
||||
// Download, the safe, expected actions, keep the trailing edge. On phones their
|
||||
// labels collapse to icon-only (aria-labelled) so three controls
|
||||
// never overflow the sheet at 360 px; the labels return at `sm+`.
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{doc.servingClass === "inline" ? (
|
||||
<DocumentOpenInTabButton
|
||||
readyUrl={inlineSrc}
|
||||
unavailable={inlineFailed}
|
||||
/>
|
||||
) : null}
|
||||
<DocumentDownloadButton
|
||||
documentId={doc.id}
|
||||
filename={doc.filename}
|
||||
|
||||
Reference in new issue
Block a user