diff --git a/sites/mainweb/app/(portal)/admin/resumes/page.tsx b/sites/mainweb/app/(portal)/admin/resumes/page.tsx
index b9bcf1fa..5030c705 100644
--- a/sites/mainweb/app/(portal)/admin/resumes/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/resumes/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { ResumePreview } from "@/components/portal/ResumePreview";
import {
BookOpen,
ChevronLeft,
@@ -276,19 +277,28 @@ export default function AdminResumesPage() {
{preview.name}
-
+
+
+ Open
+
+
+
-
)}
diff --git a/sites/mainweb/app/(portal)/api/resume/route.ts b/sites/mainweb/app/(portal)/api/resume/route.ts
index 20c139cf..5ba28886 100644
--- a/sites/mainweb/app/(portal)/api/resume/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume/route.ts
@@ -81,13 +81,11 @@ export async function POST(request: NextRequest) {
);
}
- // Lossless re-save: takes 5-15% off a text resume, and refuses a PDF that
- // will not parse here rather than handing a broken file to a sponsor later.
- let stored: Uint8Array;
+ // Parse to refuse garbage; store the original bytes. pdf-lib's re-save is
+ // smaller on text resumes and silently broken on a lot of real ones (forms,
+ // certain fonts), which then fail to open in the browser viewer.
try {
- const parsed = await PDFDocument.load(bytes, { ignoreEncryption: true });
- const compact = await parsed.save({ useObjectStreams: true });
- stored = compact.length < bytes.length ? compact : bytes;
+ await PDFDocument.load(bytes, { ignoreEncryption: true });
} catch {
return NextResponse.json(
{
@@ -97,6 +95,7 @@ export async function POST(request: NextRequest) {
{ status: 400 },
);
}
+ const stored = bytes;
const fileName = uploadedResumeFileName(
request.headers.get("x-resume-filename"),
diff --git a/sites/mainweb/components/portal/ResumePreview.tsx b/sites/mainweb/components/portal/ResumePreview.tsx
new file mode 100644
index 00000000..115b32d7
--- /dev/null
+++ b/sites/mainweb/components/portal/ResumePreview.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { looksLikePdf } from "@/lib/resume-file";
+
+/**
+ * Fetch the PDF as a blob and frame that, rather than pointing the iframe at
+ * `/api/resume/...` itself. That URL is behind `X-Frame-Options: DENY` from
+ * the edge proxy, so Chrome's viewer reports "Failed to load PDF document"
+ * even when the bytes are fine.
+ */
+export function ResumePreview({ src, title }: { src: string; title: string }) {
+ const [blobUrl, setBlobUrl] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let objectUrl: string | undefined;
+ let cancelled = false;
+
+ setBlobUrl(null);
+ setError(null);
+
+ fetch(src, { credentials: "same-origin" })
+ .then(async (res) => {
+ if (!res.ok) {
+ const body = (await res.json().catch(() => null)) as {
+ error?: string;
+ } | null;
+ throw new Error(body?.error ?? "Could not load that resume.");
+ }
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ if (!looksLikePdf(bytes)) {
+ throw new Error("Could not load that resume.");
+ }
+ // Force the PDF MIME. `res.blob()` keeps whatever Content-Type the
+ // proxy sent, and Chrome's viewer refuses anything else.
+ return new Blob([bytes], { type: "application/pdf" });
+ })
+ .then((blob) => {
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setBlobUrl(objectUrl);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(
+ err instanceof Error ? err.message : "Could not load that resume.",
+ );
+ });
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [src]);
+
+ if (error) {
+ return (
+
+ {error}{" "}
+
+ Open in a new tab
+
+
+ );
+ }
+
+ if (!blobUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/sites/mainweb/components/portal/ResumeSection.tsx b/sites/mainweb/components/portal/ResumeSection.tsx
index 144afddf..29f386f9 100644
--- a/sites/mainweb/components/portal/ResumeSection.tsx
+++ b/sites/mainweb/components/portal/ResumeSection.tsx
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
-import { FileText, Upload, Trash2, Eye, EyeOff } from "lucide-react";
+import { FileText, Upload, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { MAX_RESUME_BYTES, decodeStoredFileName } from "@/lib/resume-file";
+import { ResumePreview } from "@/components/portal/ResumePreview";
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -135,6 +136,14 @@ export function ResumeSection() {
+
+ Open
+
- {preview && (
-
- )}
+ {preview && }
>
) : (