feat: add ResumeLibraryCard to Apply workspace
This commit is contained in:
parent
d4a2107411
commit
8245333c9c
2 changed files with 202 additions and 0 deletions
|
|
@ -191,6 +191,9 @@
|
||||||
↺ Regenerate
|
↺ Regenerate
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Resume Library Card ──────────────────────────────── -->
|
||||||
|
<ResumeLibraryCard :job-id="props.jobId" class="apply__resume-card" />
|
||||||
|
|
||||||
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
||||||
<ResumeOptimizerPanel :job-id="props.jobId" />
|
<ResumeOptimizerPanel :job-id="props.jobId" />
|
||||||
|
|
||||||
|
|
@ -286,6 +289,7 @@ import { useApiFetch } from '../composables/useApi'
|
||||||
import { useAppConfigStore } from '../stores/appConfig'
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
import type { Job } from '../stores/review'
|
import type { Job } from '../stores/review'
|
||||||
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
||||||
|
import ResumeLibraryCard from './ResumeLibraryCard.vue'
|
||||||
|
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
|
|
|
||||||
198
web/src/components/ResumeLibraryCard.vue
Normal file
198
web/src/components/ResumeLibraryCard.vue
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<template>
|
||||||
|
<div class="rlc">
|
||||||
|
<div class="rlc__header">
|
||||||
|
<h3 class="rlc__title"><span aria-hidden="true">📄</span> Resume</h3>
|
||||||
|
<div class="rlc__actions">
|
||||||
|
<button class="btn-secondary rlc__switch" @click="showPicker = !showPicker">Switch</button>
|
||||||
|
<RouterLink to="/resumes" class="btn-secondary rlc__manage">Manage</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="rlc__loading">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="resume" class="rlc__resume">
|
||||||
|
<span class="rlc__name">{{ resume.name }}</span>
|
||||||
|
<span class="rlc__meta">{{ resume.word_count }} words</span>
|
||||||
|
<span v-if="resume.job_id === jobId" class="rlc__optimized-badge">✦ Optimized for this job</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rlc__empty">
|
||||||
|
No resume attached.
|
||||||
|
<RouterLink to="/resumes" class="rlc__import-link">Import one</RouterLink>
|
||||||
|
or optimize below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Picker dropdown -->
|
||||||
|
<div v-if="showPicker && allResumes.length" class="rlc__picker">
|
||||||
|
<ul class="rlc__picker-list">
|
||||||
|
<li
|
||||||
|
v-for="r in allResumes"
|
||||||
|
:key="r.id"
|
||||||
|
class="rlc__picker-item"
|
||||||
|
:class="{ 'rlc__picker-item--active': resume?.id === r.id }"
|
||||||
|
@click="switchResume(r.id)"
|
||||||
|
>
|
||||||
|
<span>{{ r.name }}</span>
|
||||||
|
<span class="rlc__picker-meta">{{ r.word_count }}w</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
interface Resume {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
word_count: number
|
||||||
|
job_id: number | null
|
||||||
|
is_default: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ jobId: number }>()
|
||||||
|
|
||||||
|
const resume = ref<Resume | null>(null)
|
||||||
|
const allResumes = ref<Resume[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showPicker = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
const [jobRes, listRes] = await Promise.all([
|
||||||
|
useApiFetch<Resume>(`/api/jobs/${props.jobId}/resume`),
|
||||||
|
useApiFetch<{ resumes: Resume[] }>('/api/resumes'),
|
||||||
|
])
|
||||||
|
resume.value = jobRes.data ?? null
|
||||||
|
allResumes.value = listRes.data?.resumes ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchResume(resumeId: number) {
|
||||||
|
showPicker.value = false
|
||||||
|
const { data } = await useApiFetch<Resume>(
|
||||||
|
`/api/jobs/${props.jobId}/resume`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ resume_id: resumeId }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (data) resume.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rlc {
|
||||||
|
background: var(--app-surface-alt, #f8fafc);
|
||||||
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__title {
|
||||||
|
font-size: var(--font-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__resume {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__meta {
|
||||||
|
font-size: var(--font-xs, 0.75rem);
|
||||||
|
color: var(--app-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__optimized-badge {
|
||||||
|
font-size: var(--font-xs, 0.75rem);
|
||||||
|
color: var(--app-accent, #6366f1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__empty {
|
||||||
|
font-size: var(--font-sm, 0.875rem);
|
||||||
|
color: var(--app-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__import-link {
|
||||||
|
color: var(--app-accent, #6366f1);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__loading {
|
||||||
|
font-size: var(--font-sm, 0.875rem);
|
||||||
|
color: var(--app-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__picker {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--app-surface, #fff);
|
||||||
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__picker-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-1, 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__picker-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
font-size: var(--font-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__picker-item:hover,
|
||||||
|
.rlc__picker-item--active {
|
||||||
|
background: var(--app-surface-alt, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rlc__picker-meta {
|
||||||
|
font-size: var(--font-xs, 0.75rem);
|
||||||
|
color: var(--app-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue