- router: add /apply/:id → ApplyWorkspaceView (lazy-loaded) - ApplyView.vue: approved job list sorted by match score; badges for match %, remote, and cover-letter draft status; links to workspace - ApplyWorkspaceView.vue: two-panel desktop layout (sticky job details left, editor right); cover letter state machine (none/queued/running/ ready/failed); auto-resize textarea; word count toolbar; Save button with dirty tracking; Download PDF (programmatic <a> click, named file); Generate with AI + Retry; Mark as Applied + Reject Listing actions; polling loop for in-flight generation tasks; toast on action - HomeView.vue: split combined "Archive Pending + Rejected" into three separate per-status archive buttons (only shown when count > 0) - dev-api.py: add GET /api/jobs/:id, POST /api/jobs/:id/applied, PATCH /api/jobs/:id/cover_letter, POST .../cover_letter/generate (wires submit_task), GET .../cover_letter/task (poll), GET .../pdf (reportlab); has_cover_letter field on list + detail responses
253 lines
7.1 KiB
Vue
253 lines
7.1 KiB
Vue
<template>
|
||
<div class="apply-list">
|
||
<header class="apply-list__header">
|
||
<h1 class="apply-list__title">Apply</h1>
|
||
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
|
||
</header>
|
||
|
||
<div v-if="loading" class="apply-list__loading" aria-live="polite">
|
||
<span class="spinner" aria-hidden="true" />
|
||
<span>Loading approved jobs…</span>
|
||
</div>
|
||
|
||
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
|
||
<span aria-hidden="true" class="empty-icon">📋</span>
|
||
<h2 class="empty-title">No approved jobs yet</h2>
|
||
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
|
||
<RouterLink to="/review" class="empty-cta">Go to Job Review →</RouterLink>
|
||
</div>
|
||
|
||
<ul v-else class="apply-list__jobs" role="list">
|
||
<li v-for="job in jobs" :key="job.id">
|
||
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
|
||
<div class="job-row__main">
|
||
<div class="job-row__badges">
|
||
<span
|
||
v-if="job.match_score !== null"
|
||
class="score-badge"
|
||
:class="scoreBadgeClass(job.match_score)"
|
||
>{{ job.match_score }}%</span>
|
||
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done">✓ Draft</span>
|
||
<span v-else class="cl-badge cl-badge--pending">○ No draft</span>
|
||
</div>
|
||
<span class="job-row__title">{{ job.title }}</span>
|
||
<span class="job-row__company">
|
||
{{ job.company }}
|
||
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
|
||
<span v-if="job.location">{{ job.location }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="job-row__meta">
|
||
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
|
||
<span class="job-row__arrow" aria-hidden="true">›</span>
|
||
</div>
|
||
</RouterLink>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { RouterLink } from 'vue-router'
|
||
import { useApiFetch } from '../composables/useApi'
|
||
|
||
interface ApprovedJob {
|
||
id: number
|
||
title: string
|
||
company: string
|
||
location: string | null
|
||
is_remote: boolean
|
||
salary: string | null
|
||
match_score: number | null
|
||
has_cover_letter: boolean
|
||
}
|
||
|
||
const jobs = ref<ApprovedJob[]>([])
|
||
const loading = ref(true)
|
||
|
||
function scoreBadgeClass(score: number) {
|
||
if (score >= 80) return 'score-badge--high'
|
||
if (score >= 60) return 'score-badge--mid'
|
||
return 'score-badge--low'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const { data } = await useApiFetch<ApprovedJob[]>('/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter')
|
||
loading.value = false
|
||
if (data) jobs.value = data
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.apply-list {
|
||
max-width: 760px;
|
||
margin: 0 auto;
|
||
padding: var(--space-8) var(--space-6);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-6);
|
||
}
|
||
|
||
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
|
||
|
||
.apply-list__title {
|
||
font-family: var(--font-display);
|
||
font-size: var(--text-2xl);
|
||
color: var(--app-primary);
|
||
}
|
||
|
||
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
|
||
|
||
.apply-list__loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
padding: var(--space-12);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
justify-content: center;
|
||
}
|
||
|
||
.apply-list__empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
padding: var(--space-16) var(--space-8);
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-icon { font-size: 3rem; }
|
||
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
|
||
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
|
||
|
||
.empty-cta {
|
||
margin-top: var(--space-2);
|
||
color: var(--app-primary);
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
transition: opacity 150ms ease;
|
||
}
|
||
.empty-cta:hover { opacity: 0.7; }
|
||
|
||
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
|
||
|
||
.job-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-4);
|
||
padding: var(--space-4) var(--space-5);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-light);
|
||
border-radius: var(--radius-lg);
|
||
text-decoration: none;
|
||
min-height: 72px;
|
||
transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease;
|
||
}
|
||
|
||
.job-row:hover {
|
||
border-color: var(--app-primary);
|
||
box-shadow: var(--shadow-sm);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
|
||
|
||
.job-row__badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-1);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.score-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 1px var(--space-2);
|
||
border-radius: 999px;
|
||
font-size: var(--text-xs);
|
||
font-weight: 700;
|
||
font-family: var(--font-mono);
|
||
}
|
||
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
|
||
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
|
||
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
|
||
|
||
.remote-badge {
|
||
padding: 1px var(--space-2);
|
||
border-radius: 999px;
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
background: var(--app-primary-light);
|
||
color: var(--app-primary);
|
||
}
|
||
|
||
.cl-badge {
|
||
padding: 1px var(--space-2);
|
||
border-radius: 999px;
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
}
|
||
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
|
||
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||
|
||
.job-row__title {
|
||
font-size: var(--text-sm);
|
||
font-weight: 700;
|
||
color: var(--color-text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.job-row__company {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.job-row__sep { color: var(--color-border); }
|
||
|
||
.job-row__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.job-row__salary {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-success);
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.job-row__arrow {
|
||
font-size: 1.25rem;
|
||
color: var(--color-text-muted);
|
||
line-height: 1;
|
||
}
|
||
|
||
.spinner {
|
||
width: 1.2rem;
|
||
height: 1.2rem;
|
||
border: 2px solid var(--color-border);
|
||
border-top-color: var(--app-primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
@media (max-width: 767px) {
|
||
.apply-list { padding: var(--space-4); gap: var(--space-4); }
|
||
.apply-list__title { font-size: var(--text-xl); }
|
||
.job-row { padding: var(--space-3) var(--space-4); }
|
||
}
|
||
</style>
|