InterviewCard: remove erroneous *100 multiplier from scoreClass and scoreLabel — match_score is stored as 0-100 in the DB, not 0-1. This was producing scores like '1490%' for jobs with a 14.9 raw score. peregrine.css: define --color-hover token for light (rgba(0,0,0,0.06)) and dark (rgba(255,255,255,0.07)). Was undefined, leaving hover states on InterviewCard, InterviewsView, ReferencesView, ContactsView silent. InterviewCard + InterviewsView: replace var(--color-primary-muted,#e8f0ff) with var(--app-primary-light). The hardcoded #e8f0ff fallback is a bright light-blue that renders on dark backgrounds when the variable is undefined. WizardTrainingStep: --font-sans → --font-body (correct token name). ResumeSyncConfirmModal, ResumeLibraryCard, ResumeOptimizerPanel, resume-review sub-pages: --font-sm → --text-sm across all occurrences. --font-sm was never defined; most had a 0.875rem fallback (which matches --text-sm) but the correct token should be referenced directly.
456 lines
17 KiB
Vue
456 lines
17 KiB
Vue
<template>
|
|
<div class="rv">
|
|
<div class="rv__header">
|
|
<h1 class="rv__title">Resume Library</h1>
|
|
<label class="btn-generate rv__import-btn">
|
|
<span aria-hidden="true">📥</span> Import
|
|
<input type="file" accept=".txt,.pdf,.docx,.odt,.yaml,.yml"
|
|
class="rv__file-input" @change="handleImport" />
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="loading" class="rv__loading">Loading…</div>
|
|
|
|
<div v-else-if="resumes.length === 0" class="rv__empty">
|
|
<p>No resumes saved yet.</p>
|
|
<p>Import a resume file or save an optimized resume from the Apply workspace.</p>
|
|
</div>
|
|
|
|
<div v-else class="rv__layout">
|
|
<!-- Left: list -->
|
|
<ul class="rv__list" role="listbox" aria-label="Saved resumes">
|
|
<li
|
|
v-for="r in resumes"
|
|
:key="r.id"
|
|
role="option"
|
|
:aria-selected="selected?.id === r.id"
|
|
class="rv__list-item"
|
|
:class="{ 'rv__list-item--active': selected?.id === r.id }"
|
|
@click="select(r)"
|
|
>
|
|
<span class="rv__item-star" :aria-label="r.is_default ? 'Default resume' : ''">
|
|
{{ r.is_default ? '★' : '☆' }}
|
|
</span>
|
|
<div class="rv__item-info">
|
|
<span class="rv__item-name">{{ r.name }}</span>
|
|
<span v-if="r.is_default" class="rv__active-badge">Active profile</span>
|
|
<span class="rv__item-meta">{{ r.word_count }} words · {{ fmtDate(r.created_at) }}</span>
|
|
<span v-if="r.job_id" class="rv__item-source">Built for job #{{ r.job_id }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Right: preview + actions -->
|
|
<div v-if="selected" class="rv__preview-pane">
|
|
<div class="rv__preview-header">
|
|
<div class="rv__preview-meta">
|
|
<h2 class="rv__preview-name">{{ editing ? editName : selected.name }}</h2>
|
|
<span class="rv__preview-words">{{ selected.word_count }} words</span>
|
|
<span v-if="selected.is_default" class="rv__default-badge">Default</span>
|
|
</div>
|
|
<div class="rv__preview-actions">
|
|
<button v-if="!selected.is_default" class="btn-secondary" @click="setDefault">
|
|
★ Set as Default
|
|
</button>
|
|
<button class="btn-generate" @click="applyToProfile"
|
|
:disabled="syncApplying"
|
|
aria-describedby="apply-to-profile-desc">
|
|
{{ syncApplying ? 'Applying…' : '⇩ Apply to profile' }}
|
|
</button>
|
|
<button class="btn-secondary" @click="toggleEdit">
|
|
{{ editing ? 'Cancel' : 'Edit' }}
|
|
</button>
|
|
<div class="rv__download-menu">
|
|
<button class="btn-secondary" @click="showDownloadMenu = !showDownloadMenu">
|
|
Download ▾
|
|
</button>
|
|
<ul v-if="showDownloadMenu" class="rv__download-dropdown">
|
|
<li><button @click="downloadTxt">Download .txt</button></li>
|
|
<li><button @click="downloadPdf">Download PDF</button></li>
|
|
<li><button @click="downloadYaml">Download YAML</button></li>
|
|
</ul>
|
|
</div>
|
|
<button class="rv__delete-btn" @click="confirmDelete"
|
|
:disabled="resumes.length === 1 || selected.is_default === 1">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit name inline -->
|
|
<input v-if="editing" v-model="editName" class="rv__edit-name-input" maxlength="80" placeholder="Resume name" />
|
|
|
|
<!-- Text editor / preview -->
|
|
<textarea
|
|
v-model="editText"
|
|
class="rv__textarea"
|
|
:readonly="!editing"
|
|
spellcheck="false"
|
|
aria-label="Resume text"
|
|
/>
|
|
|
|
<div v-if="editing" class="rv__edit-actions">
|
|
<button class="btn-generate" :disabled="saving" @click="saveEdit">
|
|
{{ saving ? 'Saving…' : 'Save' }}
|
|
</button>
|
|
<button class="btn-secondary" @click="toggleEdit">Discard</button>
|
|
</div>
|
|
|
|
<p id="apply-to-profile-desc" class="rv__sync-desc">
|
|
Replaces your resume profile content with this version. Your current profile is backed up first.
|
|
</p>
|
|
<p v-if="selected.synced_at" class="rv__synced-at">
|
|
Last synced to profile: {{ fmtDate(selected.synced_at) }}
|
|
</p>
|
|
|
|
<p v-if="actionError" class="rv__error" role="alert">{{ actionError }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Persistent sync notice (dismissible) -->
|
|
<div v-if="syncNotice" class="rv__sync-notice" role="status" aria-live="polite">
|
|
Profile updated. Previous content backed up as
|
|
<strong>{{ syncNotice.backupName }}</strong>.
|
|
<button class="rv__sync-notice-dismiss" @click="dismissSyncNotice" aria-label="Dismiss">✕</button>
|
|
</div>
|
|
|
|
<ResumeSyncConfirmModal
|
|
:show="showSyncModal"
|
|
:current-summary="buildSummary(resumes.find(r => r.is_default === 1) ?? null)"
|
|
:source-summary="buildSummary(selected)"
|
|
:blank-fields="selected?.struct_json
|
|
? (JSON.parse(selected.struct_json).experience?.length
|
|
? ['experience[].industry']
|
|
: [])
|
|
: []"
|
|
@confirm="confirmApplyToProfile"
|
|
@cancel="showSyncModal = false"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { onBeforeRouteLeave } from 'vue-router'
|
|
import { useApiFetch } from '../composables/useApi'
|
|
import ResumeSyncConfirmModal from '../components/ResumeSyncConfirmModal.vue'
|
|
|
|
interface Resume {
|
|
id: number; name: string; source: string; job_id: number | null
|
|
text: string; struct_json: string | null; word_count: number
|
|
is_default: number; created_at: string; updated_at: string
|
|
synced_at: string | null
|
|
}
|
|
|
|
const resumes = ref<Resume[]>([])
|
|
const selected = ref<Resume | null>(null)
|
|
const loading = ref(true)
|
|
const editing = ref(false)
|
|
const editName = ref('')
|
|
const editText = ref('')
|
|
const saving = ref(false)
|
|
const actionError = ref('')
|
|
const showDownloadMenu = ref(false)
|
|
|
|
const showSyncModal = ref(false)
|
|
const syncApplying = ref(false)
|
|
const syncNotice = ref<{ backupName: string; backupId: number } | null>(null)
|
|
|
|
interface ContentSummary { name: string; careerSummary: string; latestRole: string }
|
|
|
|
function buildSummary(r: Resume | null): ContentSummary {
|
|
if (!r) return { name: '', careerSummary: '', latestRole: '' }
|
|
try {
|
|
const s = r.struct_json ? JSON.parse(r.struct_json) : {}
|
|
const exp = Array.isArray(s.experience) ? s.experience[0] : null
|
|
return {
|
|
name: s.name || r.name,
|
|
careerSummary: (s.career_summary || '').slice(0, 120),
|
|
latestRole: exp ? `${exp.title || ''} at ${exp.company || ''}`.replace(/^ at | at $/, '') : '',
|
|
}
|
|
} catch { return { name: r.name, careerSummary: '', latestRole: '' } }
|
|
}
|
|
|
|
function fmtDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
}
|
|
|
|
async function loadList() {
|
|
loading.value = true
|
|
const { data } = await useApiFetch<{ resumes: Resume[] }>('/api/resumes')
|
|
resumes.value = data?.resumes ?? []
|
|
if (resumes.value.length && !selected.value) select(resumes.value[0])
|
|
loading.value = false
|
|
}
|
|
|
|
function select(r: Resume) {
|
|
selected.value = r
|
|
editing.value = false
|
|
editName.value = r.name
|
|
editText.value = r.text
|
|
actionError.value = ''
|
|
showDownloadMenu.value = false
|
|
}
|
|
|
|
function toggleEdit() {
|
|
if (editing.value) {
|
|
editing.value = false
|
|
editName.value = selected.value?.name ?? ''
|
|
editText.value = selected.value?.text ?? ''
|
|
} else {
|
|
editing.value = true
|
|
}
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!selected.value) return
|
|
saving.value = true; actionError.value = ''
|
|
const { data, error } = await useApiFetch<Resume>(
|
|
`/api/resumes/${selected.value.id}`,
|
|
{ method: 'PATCH', body: JSON.stringify({ name: editName.value, text: editText.value }),
|
|
headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
saving.value = false
|
|
if (error || !data) { actionError.value = 'Save failed.'; return }
|
|
const idx = resumes.value.findIndex(r => r.id === data.id)
|
|
if (idx >= 0) resumes.value[idx] = data
|
|
selected.value = data
|
|
editing.value = false
|
|
}
|
|
|
|
async function setDefault() {
|
|
if (!selected.value) return
|
|
actionError.value = ''
|
|
const { error } = await useApiFetch(`/api/resumes/${selected.value.id}/set-default`, { method: 'POST' })
|
|
if (error) { actionError.value = 'Failed to set default.'; return }
|
|
await loadList()
|
|
if (selected.value) {
|
|
const refreshed = resumes.value.find(r => r.id === selected.value!.id)
|
|
if (refreshed) select(refreshed)
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!selected.value) return
|
|
if (!window.confirm(`Delete "${selected.value.name}"? This cannot be undone.`)) return
|
|
actionError.value = ''
|
|
const { error } = await useApiFetch(`/api/resumes/${selected.value.id}`, { method: 'DELETE' })
|
|
if (error) { actionError.value = 'Delete failed — check if this is the default resume.'; return }
|
|
selected.value = null
|
|
await loadList()
|
|
}
|
|
|
|
async function applyToProfile() {
|
|
if (!selected.value) return
|
|
showSyncModal.value = true
|
|
}
|
|
|
|
async function confirmApplyToProfile() {
|
|
if (!selected.value) return
|
|
showSyncModal.value = false
|
|
syncApplying.value = true
|
|
actionError.value = ''
|
|
const { data, error } = await useApiFetch<{
|
|
ok: boolean; backup_id: number; backup_name: string
|
|
}>(`/api/resumes/${selected.value.id}/apply-to-profile`, { method: 'POST' })
|
|
syncApplying.value = false
|
|
if (error || !data?.ok) {
|
|
actionError.value = 'Profile sync failed — please try again.'
|
|
return
|
|
}
|
|
syncNotice.value = { backupName: data.backup_name, backupId: data.backup_id }
|
|
await loadList()
|
|
}
|
|
|
|
function dismissSyncNotice() { syncNotice.value = null }
|
|
|
|
async function handleImport(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
actionError.value = ''
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const { data, error } = await useApiFetch<Resume>('/api/resumes/import', { method: 'POST', body: form })
|
|
if (error || !data) { actionError.value = 'Import failed — check file format.'; return }
|
|
resumes.value = [data, ...resumes.value]
|
|
select(data)
|
|
}
|
|
|
|
function downloadTxt() {
|
|
if (!selected.value) return
|
|
const blob = new Blob([selected.value.text], { type: 'text/plain' })
|
|
const a = document.createElement('a')
|
|
a.href = URL.createObjectURL(blob)
|
|
a.download = `${selected.value.name.replace(/\s+/g, '-')}.txt`
|
|
a.click()
|
|
URL.revokeObjectURL(a.href)
|
|
showDownloadMenu.value = false
|
|
}
|
|
|
|
function downloadPdf() {
|
|
if (!selected.value) return
|
|
window.open(`/api/resumes/${selected.value.id}/export-pdf`, '_blank')
|
|
showDownloadMenu.value = false
|
|
}
|
|
|
|
function downloadYaml() {
|
|
if (!selected.value) return
|
|
window.open(`/api/resumes/${selected.value.id}/export-yaml`, '_blank')
|
|
showDownloadMenu.value = false
|
|
}
|
|
|
|
onMounted(loadList)
|
|
|
|
onBeforeRouteLeave(() => {
|
|
if (editing.value && (editName.value !== selected.value?.name || editText.value !== selected.value?.text)) {
|
|
const confirmed = window.confirm(
|
|
`You have unsaved edits to "${selected.value?.name}". Leave without saving?`
|
|
)
|
|
if (!confirmed) return false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.rv { display: flex; flex-direction: column; gap: var(--space-4, 1rem); padding: var(--space-5, 1.25rem); height: 100%; }
|
|
|
|
.rv__header { display: flex; align-items: center; justify-content: space-between; }
|
|
.rv__title { font-size: var(--font-xl, 1.25rem); font-weight: 700; margin: 0; }
|
|
.rv__file-input { display: none; }
|
|
.rv__import-btn { cursor: pointer; }
|
|
|
|
.rv__layout { display: grid; grid-template-columns: 260px 1fr; gap: var(--space-4, 1rem); flex: 1; min-height: 0; }
|
|
|
|
.rv__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1, 0.25rem); overflow-y: auto; }
|
|
.rv__list-item {
|
|
display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem);
|
|
padding: var(--space-3, 0.75rem); border-radius: var(--radius-md, 0.5rem);
|
|
cursor: pointer; border: 1px solid transparent;
|
|
}
|
|
.rv__list-item:hover { background: var(--color-surface-alt, #f8fafc); }
|
|
.rv__list-item--active { background: var(--color-surface-alt, #f8fafc); border-color: var(--color-accent, #6366f1); }
|
|
|
|
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
|
|
.rv__item-info { display: flex; flex-direction: column; gap: 2px; }
|
|
.rv__item-name { font-weight: 500; font-size: var(--text-sm); }
|
|
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
|
|
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
|
|
|
|
.rv__preview-pane { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); min-height: 0; }
|
|
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
|
|
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
|
|
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
|
|
.rv__preview-words { font-size: var(--text-sm); color: var(--color-text-muted, #64748b); }
|
|
.rv__default-badge {
|
|
font-size: var(--font-xs, 0.75rem); font-weight: 600;
|
|
background: var(--color-success, #16a34a); color: #fff;
|
|
padding: 2px 8px; border-radius: 9999px;
|
|
}
|
|
.rv__preview-actions { display: flex; gap: var(--space-2, 0.5rem); flex-wrap: wrap; align-items: center; }
|
|
.rv__delete-btn {
|
|
color: var(--color-error, #dc2626); background: none;
|
|
border: 1px solid var(--color-error, #dc2626);
|
|
border-radius: var(--radius-md, 0.5rem);
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
cursor: pointer; font-size: var(--text-sm);
|
|
}
|
|
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
.rv__edit-name-input {
|
|
width: 100%; padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
|
font-size: var(--font-base, 1rem);
|
|
}
|
|
.rv__textarea {
|
|
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
|
|
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
|
font-family: monospace; font-size: var(--text-sm); resize: vertical;
|
|
background: var(--color-surface-alt, #f8fafc);
|
|
color: var(--color-text);
|
|
}
|
|
.rv__textarea:not([readonly]) { background: var(--color-surface); }
|
|
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
|
.rv__error { color: var(--color-error, #dc2626); font-size: var(--text-sm); }
|
|
|
|
.rv__download-menu { position: relative; }
|
|
.rv__download-dropdown {
|
|
position: absolute; top: 100%; right: 0; z-index: 10;
|
|
background: var(--color-surface, #fff); border: 1px solid var(--color-border, #e2e8f0);
|
|
border-radius: var(--radius-md, 0.5rem); list-style: none; margin: 4px 0; padding: var(--space-1, 0.25rem);
|
|
min-width: 140px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
}
|
|
.rv__download-dropdown button {
|
|
width: 100%; text-align: left; background: none; border: none;
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
cursor: pointer; font-size: var(--text-sm); border-radius: var(--radius-sm, 0.25rem);
|
|
}
|
|
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
|
|
|
|
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--text-sm); }
|
|
|
|
/* Button styles — defined locally since no global button sheet exists yet */
|
|
.btn-secondary {
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md, 0.5rem);
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
font-size: var(--text-sm);
|
|
white-space: nowrap;
|
|
}
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: var(--color-surface-alt);
|
|
color: var(--color-text);
|
|
}
|
|
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.btn-generate {
|
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
background: var(--color-accent);
|
|
color: var(--color-text-inverse);
|
|
border: none;
|
|
border-radius: var(--radius-md, 0.5rem);
|
|
cursor: pointer;
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1, 0.25rem);
|
|
}
|
|
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
@media (max-width: 640px) {
|
|
.rv__layout { grid-template-columns: 1fr; }
|
|
.rv__list { max-height: 200px; }
|
|
}
|
|
|
|
.rv__active-badge {
|
|
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
|
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-surface-alt));
|
|
color: var(--color-primary);
|
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
|
|
border-radius: var(--radius-sm, 0.25rem);
|
|
padding: 1px 6px; margin-left: var(--space-1);
|
|
}
|
|
.rv__sync-desc {
|
|
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
|
|
}
|
|
.rv__synced-at {
|
|
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
|
|
}
|
|
.rv__sync-notice {
|
|
position: fixed; bottom: var(--space-6); left: 50%; transform: translateX(-50%);
|
|
background: var(--color-surface-raised);
|
|
border: 1px solid var(--color-primary);
|
|
border-radius: var(--radius-md); padding: var(--space-3) var(--space-5);
|
|
font-size: 0.9rem; z-index: 500; max-width: 480px;
|
|
display: flex; gap: var(--space-3); align-items: center;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
|
}
|
|
.rv__sync-notice-dismiss {
|
|
background: none; border: none; cursor: pointer;
|
|
color: var(--color-text-muted); font-size: 1rem; flex-shrink: 0;
|
|
}
|
|
</style>
|