feat: add ResumesView standalone resume library manager
This commit is contained in:
parent
f7b719f854
commit
d4a2107411
1 changed files with 306 additions and 0 deletions
306
web/src/views/ResumesView.vue
Normal file
306
web/src/views/ResumesView.vue
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
<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 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-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 v-if="actionError" class="rv__error" role="alert">{{ actionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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 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)
|
||||
</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(--app-surface-alt, #f8fafc); }
|
||||
.rv__list-item--active { background: var(--app-surface-alt, #f8fafc); border-color: var(--app-accent, #6366f1); }
|
||||
|
||||
.rv__item-star { color: var(--app-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(--font-sm, 0.875rem); }
|
||||
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--app-text-muted, #64748b); }
|
||||
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--app-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(--font-sm, 0.875rem); color: var(--app-text-muted, #64748b); }
|
||||
.rv__default-badge {
|
||||
font-size: var(--font-xs, 0.75rem); font-weight: 600;
|
||||
background: var(--app-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(--app-danger, #dc2626); background: none;
|
||||
border: 1px solid var(--app-danger, #dc2626);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
||||
}
|
||||
.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(--app-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(--app-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
||||
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
|
||||
background: var(--app-surface-alt, #f8fafc);
|
||||
}
|
||||
.rv__textarea:not([readonly]) { background: var(--app-surface, #fff); }
|
||||
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
||||
.rv__error { color: var(--app-danger, #dc2626); font-size: var(--font-sm, 0.875rem); }
|
||||
|
||||
.rv__download-menu { position: relative; }
|
||||
.rv__download-dropdown {
|
||||
position: absolute; top: 100%; right: 0; z-index: 10;
|
||||
background: var(--app-surface, #fff); border: 1px solid var(--app-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(--font-sm, 0.875rem); border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
.rv__download-dropdown button:hover { background: var(--app-surface-alt, #f8fafc); }
|
||||
|
||||
.rv__loading, .rv__empty { color: var(--app-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rv__layout { grid-template-columns: 1fr; }
|
||||
.rv__list { max-height: 200px; }
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue