CV bank: in-app preview — view a stored CV without downloading
Deploy to S3 / deploy (push) Successful in 37s Details

Each bank row gains an eye action that opens the PDF in a modal iframe.
fetchBlobUrl learned an optional MIME re-type: /documents/download sends
octet-stream, which would trigger a download instead of the inline
viewer, so the blob is re-wrapped as application/pdf. Object URL revoked
on close. Download and remove actions unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-27 19:26:31 +05:00
parent 9f610a9078
commit 0b19f3ce08
4 changed files with 66 additions and 8 deletions

View File

@ -24,7 +24,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-_PGQLp_7.js"></script>
<script type="module" crossorigin src="/assets/index-CVZNz-Ck.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C1VjJy57.css">
</head>
<body>

View File

@ -12,7 +12,7 @@
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { downloadFile, request } from '../lib/apiClient'
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
*
@ -80,6 +80,18 @@ export function downloadCvBankCv(id) {
return downloadFile('/documents/download', { params: { manual_upload_candidate_id: id } })
}
/**
* Object URL of a stored CV for IN-APP preview (no download). The route sends
* octet-stream, so the blob is re-typed to application/pdf for the browser's
* inline viewer. Caller revokes the URL when the preview closes.
*/
export function viewCvBankCv(id) {
return fetchBlobUrl('/documents/download', {
params: { manual_upload_candidate_id: id },
type: 'application/pdf',
})
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`

View File

@ -188,12 +188,14 @@ export async function downloadFile(path, { params, auth = true, filename } = {})
}
/**
* Authenticated binary GET returning an object URL for <img>/<video> use
* a plain src attribute cannot carry the bearer token. Returns null on 404
* (the resource legitimately not existing, e.g. a job with no cover image).
* Callers own the URL: revoke it with URL.revokeObjectURL when done.
* Authenticated binary GET returning an object URL for <img>/<video>/<iframe>
* use a plain src attribute cannot carry the bearer token. Returns null on
* 404 (the resource legitimately not existing, e.g. a job with no cover
* image). `type` re-wraps the blob with that MIME type: routes that send
* application/octet-stream would otherwise trigger a download instead of the
* browser's inline viewer. Callers own the URL: revoke it when done.
*/
export async function fetchBlobUrl(path, { params, auth = true } = {}) {
export async function fetchBlobUrl(path, { params, auth = true, type } = {}) {
if (auth && isExpiring()) {
try {
await refreshSession()
@ -235,7 +237,8 @@ export async function fetchBlobUrl(path, { params, auth = true } = {}) {
if (!res.ok) {
throw new ApiError(res.statusText || 'Request failed', res.status, null)
}
return URL.createObjectURL(await res.blob())
const blob = await res.blob()
return URL.createObjectURL(type ? new Blob([blob], { type }) : blob)
}
function filenameFromDisposition(header) {

View File

@ -16,6 +16,7 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
@ -346,12 +347,31 @@ export default function CvImport() {
function CvBank() {
const { toast } = useToast()
const qc = useQueryClient()
const [preview, setPreview] = useState(null) // { name, url } url is an object URL we own
const bankQuery = useQuery({
queryKey: qk.cvBank.list(),
queryFn: () => candidatesApi.listCvBank({ top: 200 }),
})
const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : []
async function view(row) {
try {
const url = await candidatesApi.viewCvBankCv(row.id)
if (!url) {
toast('The CV file could not be found', 'error')
return
}
setPreview({ name: row.file_name || 'CV', url })
} catch (err) {
toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
}
}
function closePreview() {
if (preview) URL.revokeObjectURL(preview.url)
setPreview(null)
}
const removing = useMutation({
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
onSuccess: () => {
@ -400,6 +420,9 @@ function CvBank() {
</div>
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" />
</button>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
@ -420,6 +443,26 @@ function CvBank() {
</div>
))}
</div>
{preview && (
<Modal
title={preview.name}
subtitle="CV preview"
size="modal-lg"
onClose={closePreview}
footer={
<button className="btn btn-secondary" onClick={closePreview}>Close</button>
}
>
{/* Blob URL re-typed to application/pdf, so the browser's built-in
viewer renders inline instead of triggering a download. */}
<iframe
src={preview.url}
title={`Preview of ${preview.name}`}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
/>
</Modal>
)}
</div>
)
}