feat(settings): Resume Profile tab — store, view, API endpoints, identity sync
This commit is contained in:
parent
6093275549
commit
56857dc989
4 changed files with 524 additions and 1 deletions
73
dev-api.py
73
dev-api.py
|
|
@ -14,7 +14,8 @@ from urllib.parse import urlparse
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
import yaml
|
||||||
|
from fastapi import FastAPI, HTTPException, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
@ -1017,3 +1018,73 @@ def save_profile(payload: UserProfilePayload):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, f"Could not save profile: {e}")
|
raise HTTPException(500, f"Could not save profile: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings: Resume Profile endpoints ───────────────────────────────────────
|
||||||
|
|
||||||
|
class WorkEntry(BaseModel):
|
||||||
|
title: str = ""; company: str = ""; period: str = ""; location: str = ""
|
||||||
|
industry: str = ""; responsibilities: str = ""; skills: List[str] = []
|
||||||
|
|
||||||
|
class ResumePayload(BaseModel):
|
||||||
|
name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = ""
|
||||||
|
surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = ""
|
||||||
|
experience: List[WorkEntry] = []
|
||||||
|
salary_min: int = 0; salary_max: int = 0; notice_period: str = ""
|
||||||
|
remote: bool = False; relocation: bool = False
|
||||||
|
assessment: bool = False; background_check: bool = False
|
||||||
|
gender: str = ""; pronouns: str = ""; ethnicity: str = ""
|
||||||
|
veteran_status: str = ""; disability: str = ""
|
||||||
|
skills: List[str] = []; domains: List[str] = []; keywords: List[str] = []
|
||||||
|
|
||||||
|
RESUME_PATH = Path("config/plain_text_resume.yaml")
|
||||||
|
|
||||||
|
@app.get("/api/settings/resume")
|
||||||
|
def get_resume():
|
||||||
|
try:
|
||||||
|
if not RESUME_PATH.exists():
|
||||||
|
return {"exists": False}
|
||||||
|
with open(RESUME_PATH) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
data["exists"] = True
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.put("/api/settings/resume")
|
||||||
|
def save_resume(payload: ResumePayload):
|
||||||
|
try:
|
||||||
|
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(RESUME_PATH, "w") as f:
|
||||||
|
yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/settings/resume/blank")
|
||||||
|
def create_blank_resume():
|
||||||
|
try:
|
||||||
|
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not RESUME_PATH.exists():
|
||||||
|
with open(RESUME_PATH, "w") as f:
|
||||||
|
yaml.dump({}, f)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/settings/resume/upload")
|
||||||
|
async def upload_resume(file: UploadFile):
|
||||||
|
from scripts.resume_parser import structure_resume
|
||||||
|
import tempfile, os
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(await file.read())
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
result, error = structure_resume(tmp_path)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
if error:
|
||||||
|
return {"ok": False, "error": error, "data": result}
|
||||||
|
result["exists"] = True
|
||||||
|
return {"ok": True, "data": result}
|
||||||
|
|
|
||||||
42
web/src/stores/settings/resume.test.ts
Normal file
42
web/src/stores/settings/resume.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useResumeStore } from './resume'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useResumeStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('hasResume is false before load', () => {
|
||||||
|
expect(useResumeStore().hasResume).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() sets hasResume from API exists flag', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { exists: true, name: 'Meg', email: '', phone: '',
|
||||||
|
linkedin_url: '', surname: '', address: '', city: '', zip_code: '', date_of_birth: '',
|
||||||
|
experience: [], salary_min: 0, salary_max: 0, notice_period: '', remote: false,
|
||||||
|
relocation: false, assessment: false, background_check: false,
|
||||||
|
gender: '', pronouns: '', ethnicity: '', veteran_status: '', disability: '',
|
||||||
|
skills: [], domains: [], keywords: [],
|
||||||
|
}, error: null })
|
||||||
|
const store = useResumeStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.hasResume).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncFromProfile() copies identity fields', () => {
|
||||||
|
const store = useResumeStore()
|
||||||
|
store.syncFromProfile({ name: 'Test', email: 'a@b.com', phone: '555', linkedin_url: 'li.com/test' })
|
||||||
|
expect(store.name).toBe('Test')
|
||||||
|
expect(store.email).toBe('a@b.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() empty-state when exists=false', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { exists: false }, error: null })
|
||||||
|
const store = useResumeStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.hasResume).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
95
web/src/stores/settings/resume.ts
Normal file
95
web/src/stores/settings/resume.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export interface WorkEntry {
|
||||||
|
title: string; company: string; period: string; location: string
|
||||||
|
industry: string; responsibilities: string; skills: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useResumeStore = defineStore('settings/resume', () => {
|
||||||
|
const hasResume = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Identity (synced from profile store)
|
||||||
|
const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('')
|
||||||
|
// Resume-only contact
|
||||||
|
const surname = ref(''); const address = ref(''); const city = ref('')
|
||||||
|
const zip_code = ref(''); const date_of_birth = ref('')
|
||||||
|
// Experience
|
||||||
|
const experience = ref<WorkEntry[]>([])
|
||||||
|
// Prefs
|
||||||
|
const salary_min = ref(0); const salary_max = ref(0); const notice_period = ref('')
|
||||||
|
const remote = ref(false); const relocation = ref(false)
|
||||||
|
const assessment = ref(false); const background_check = ref(false)
|
||||||
|
// Self-ID
|
||||||
|
const gender = ref(''); const pronouns = ref(''); const ethnicity = ref('')
|
||||||
|
const veteran_status = ref(''); const disability = ref('')
|
||||||
|
// Keywords
|
||||||
|
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
|
||||||
|
|
||||||
|
function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
|
||||||
|
name.value = p.name; email.value = p.email
|
||||||
|
phone.value = p.phone; linkedin_url.value = p.linkedin_url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
|
||||||
|
loading.value = false
|
||||||
|
if (error || !data || !data.exists) { hasResume.value = false; return }
|
||||||
|
hasResume.value = true
|
||||||
|
name.value = String(data.name ?? ''); email.value = String(data.email ?? '')
|
||||||
|
phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '')
|
||||||
|
surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '')
|
||||||
|
city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '')
|
||||||
|
date_of_birth.value = String(data.date_of_birth ?? '')
|
||||||
|
experience.value = (data.experience as WorkEntry[]) ?? []
|
||||||
|
salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0)
|
||||||
|
notice_period.value = String(data.notice_period ?? '')
|
||||||
|
remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation)
|
||||||
|
assessment.value = Boolean(data.assessment); background_check.value = Boolean(data.background_check)
|
||||||
|
gender.value = String(data.gender ?? ''); pronouns.value = String(data.pronouns ?? '')
|
||||||
|
ethnicity.value = String(data.ethnicity ?? ''); veteran_status.value = String(data.veteran_status ?? '')
|
||||||
|
disability.value = String(data.disability ?? '')
|
||||||
|
skills.value = (data.skills as string[]) ?? []
|
||||||
|
domains.value = (data.domains as string[]) ?? []
|
||||||
|
keywords.value = (data.keywords as string[]) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true; saveError.value = null
|
||||||
|
const body = {
|
||||||
|
name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value,
|
||||||
|
surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value,
|
||||||
|
date_of_birth: date_of_birth.value, experience: experience.value,
|
||||||
|
salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value,
|
||||||
|
remote: remote.value, relocation: relocation.value,
|
||||||
|
assessment: assessment.value, background_check: background_check.value,
|
||||||
|
gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value,
|
||||||
|
veteran_status: veteran_status.value, disability: disability.value,
|
||||||
|
skills: skills.value, domains: domains.value, keywords: keywords.value,
|
||||||
|
}
|
||||||
|
const { error } = await useApiFetch('/api/settings/resume', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (error) saveError.value = 'Save failed — please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBlank() {
|
||||||
|
const { error } = await useApiFetch('/api/settings/resume/blank', { method: 'POST' })
|
||||||
|
if (!error) { hasResume.value = true; await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasResume, loading, saving, saveError,
|
||||||
|
name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth,
|
||||||
|
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
|
||||||
|
gender, pronouns, ethnicity, veteran_status, disability,
|
||||||
|
skills, domains, keywords,
|
||||||
|
syncFromProfile, load, save, createBlank,
|
||||||
|
}
|
||||||
|
})
|
||||||
315
web/src/views/settings/ResumeProfileView.vue
Normal file
315
web/src/views/settings/ResumeProfileView.vue
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<template>
|
||||||
|
<div class="resume-profile">
|
||||||
|
<h2>Resume Profile</h2>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!store.hasResume && !store.loading" class="empty-state">
|
||||||
|
<p>No resume found. Choose how to get started:</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<!-- Upload -->
|
||||||
|
<div class="empty-card">
|
||||||
|
<h3>Upload & Parse</h3>
|
||||||
|
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
|
||||||
|
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
|
||||||
|
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Blank -->
|
||||||
|
<div class="empty-card">
|
||||||
|
<h3>Fill in Manually</h3>
|
||||||
|
<p>Start with a blank form and fill in your details.</p>
|
||||||
|
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
||||||
|
</div>
|
||||||
|
<!-- Wizard -->
|
||||||
|
<div class="empty-card">
|
||||||
|
<h3>Run Setup Wizard</h3>
|
||||||
|
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
||||||
|
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full form (when resume exists) -->
|
||||||
|
<template v-else-if="store.hasResume">
|
||||||
|
<!-- Personal Information -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Personal Information</h3>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>First Name <span class="sync-label">← from My Profile</span></label>
|
||||||
|
<input v-model="store.name" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Last Name</label>
|
||||||
|
<input v-model="store.surname" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Email <span class="sync-label">← from My Profile</span></label>
|
||||||
|
<input v-model="store.email" type="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Phone <span class="sync-label">← from My Profile</span></label>
|
||||||
|
<input v-model="store.phone" type="tel" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>LinkedIn URL <span class="sync-label">← from My Profile</span></label>
|
||||||
|
<input v-model="store.linkedin_url" type="url" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Address</label>
|
||||||
|
<input v-model="store.address" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>City</label>
|
||||||
|
<input v-model="store.city" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>ZIP Code</label>
|
||||||
|
<input v-model="store.zip_code" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Work Experience -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Work Experience</h3>
|
||||||
|
<div v-for="(entry, idx) in store.experience" :key="idx" class="experience-card">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Job Title</label>
|
||||||
|
<input v-model="entry.title" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Company</label>
|
||||||
|
<input v-model="entry.company" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Period</label>
|
||||||
|
<input v-model="entry.period" placeholder="e.g. Jan 2022 – Present" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Location</label>
|
||||||
|
<input v-model="entry.location" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Industry</label>
|
||||||
|
<input v-model="entry.industry" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Responsibilities</label>
|
||||||
|
<textarea v-model="entry.responsibilities" rows="4" />
|
||||||
|
</div>
|
||||||
|
<button class="remove-btn" @click="removeExperience(idx)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<button @click="addExperience">+ Add Position</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Preferences -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Preferences & Availability</h3>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Salary Min</label>
|
||||||
|
<input v-model.number="store.salary_min" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Salary Max</label>
|
||||||
|
<input v-model.number="store.salary_max" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Notice Period</label>
|
||||||
|
<input v-model="store.notice_period" />
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="store.remote" /> Open to remote
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="store.relocation" /> Open to relocation
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="store.assessment" /> Willing to complete assessments
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="store.background_check" /> Willing to undergo background check
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Self-ID (collapsible) -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>
|
||||||
|
Self-Identification
|
||||||
|
<button class="toggle-btn" @click="showSelfId = !showSelfId">
|
||||||
|
{{ showSelfId ? '▲ Hide' : '▼ Show' }}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<p class="section-note">Optional. Used only for your personal tracking.</p>
|
||||||
|
<template v-if="showSelfId">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Gender</label>
|
||||||
|
<input v-model="store.gender" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Pronouns</label>
|
||||||
|
<input v-model="store.pronouns" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Ethnicity</label>
|
||||||
|
<input v-model="store.ethnicity" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Veteran Status</label>
|
||||||
|
<input v-model="store.veteran_status" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Disability</label>
|
||||||
|
<input v-model="store.disability" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skills & Keywords -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Skills & Keywords</h3>
|
||||||
|
<div class="tag-section">
|
||||||
|
<label>Skills</label>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="skill in store.skills" :key="skill" class="tag">
|
||||||
|
{{ skill }} <button @click="removeTag('skills', skill)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="skillInput" @keydown.enter.prevent="addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
|
||||||
|
</div>
|
||||||
|
<div class="tag-section">
|
||||||
|
<label>Domains</label>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="domain in store.domains" :key="domain" class="tag">
|
||||||
|
{{ domain }} <button @click="removeTag('domains', domain)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="domainInput" @keydown.enter.prevent="addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
|
||||||
|
</div>
|
||||||
|
<div class="tag-section">
|
||||||
|
<label>Keywords</label>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="kw in store.keywords" :key="kw" class="tag">
|
||||||
|
{{ kw }} <button @click="removeTag('keywords', kw)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="kwInput" @keydown.enter.prevent="addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
|
||||||
|
{{ store.saving ? 'Saving…' : 'Save Resume' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useResumeStore } from '../../stores/settings/resume'
|
||||||
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const store = useResumeStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const showSelfId = ref(false)
|
||||||
|
const skillInput = ref('')
|
||||||
|
const domainInput = ref('')
|
||||||
|
const kwInput = ref('')
|
||||||
|
const uploadError = ref<string | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.load()
|
||||||
|
store.syncFromProfile({
|
||||||
|
name: profileStore.name,
|
||||||
|
email: profileStore.email,
|
||||||
|
phone: profileStore.phone,
|
||||||
|
linkedin_url: profileStore.linkedin_url,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function addExperience() {
|
||||||
|
store.experience.push({ title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExperience(idx: number) {
|
||||||
|
store.experience.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || store[field].includes(trimmed)) return
|
||||||
|
store[field].push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const arr = store[field] as string[]
|
||||||
|
const idx = arr.indexOf(value)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(event: Event) {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
uploadError.value = null
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { data, error } = await useApiFetch<{ ok: boolean; data?: Record<string, unknown>; error?: string }>(
|
||||||
|
'/api/settings/resume/upload',
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
)
|
||||||
|
if (error || !data?.ok) {
|
||||||
|
uploadError.value = data?.error ?? error ?? 'Upload failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.data) {
|
||||||
|
await store.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||||
|
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
|
||||||
|
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
|
||||||
|
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.field-row input, .field-row textarea, .field-row select {
|
||||||
|
background: var(--color-surface-2, rgba(255,255,255,0.05));
|
||||||
|
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary, #e2e8f0);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
|
||||||
|
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
|
||||||
|
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
|
||||||
|
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
|
||||||
|
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
|
||||||
|
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
|
||||||
|
.empty-card h3 { margin-bottom: 8px; }
|
||||||
|
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||||
|
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
|
||||||
|
.tag-section { margin-bottom: var(--space-4, 24px); }
|
||||||
|
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
|
||||||
|
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
|
||||||
|
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
|
||||||
|
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
|
||||||
|
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.error { color: #ef4444; font-size: 0.82rem; }
|
||||||
|
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||||
|
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue