peregrine/web/src/views/settings/MyProfileView.vue
pyr0ball 1ef418ba00 feat(settings): My Profile tab — store, view, API endpoints
- Add useProfileStore (settings/profile) with load/save, all profile fields,
  loading/saving/saveError state, and graceful resume sync-identity call
- Add MyProfileView.vue: Identity, Mission & Values, NDA Companies, and
  Research Brief Preferences sections; autosave on NDA add/remove and
  debounced autosave (400ms) on research checkbox changes
- Add GET/PUT /api/settings/profile endpoints to dev-api.py with YAML
  field mapping (linkedin ↔ linkedin_url, candidate_*_focus ↔ *_focus,
  mission_preferences dict ↔ list of {industry, note})
- 3 new store tests pass; full suite 26/26 green
2026-03-21 02:28:14 -07:00

565 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="my-profile">
<header class="page-header">
<h2>My Profile</h2>
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
</header>
<div v-if="store.loading" class="loading-state">Loading profile</div>
<template v-else>
<!-- Identity -->
<section class="form-section">
<h3 class="section-title">Identity</h3>
<div class="field-row">
<label class="field-label" for="profile-name">Full name</label>
<input id="profile-name" v-model="store.name" type="text" class="text-input" placeholder="Your Name" />
</div>
<div class="field-row">
<label class="field-label" for="profile-email">Email</label>
<input id="profile-email" v-model="store.email" type="email" class="text-input" placeholder="you@example.com" />
</div>
<div class="field-row">
<label class="field-label" for="profile-phone">Phone</label>
<input id="profile-phone" v-model="store.phone" type="tel" class="text-input" placeholder="555-000-0000" />
</div>
<div class="field-row">
<label class="field-label" for="profile-linkedin">LinkedIn URL</label>
<input id="profile-linkedin" v-model="store.linkedin_url" type="url" class="text-input" placeholder="linkedin.com/in/yourprofile" />
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-summary">Career summary</label>
<textarea
id="profile-summary"
v-model="store.career_summary"
class="text-area"
rows="5"
placeholder="23 sentences summarising your experience and focus."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateSummary"
:disabled="generatingSummary"
>{{ generatingSummary ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-voice">Candidate voice</label>
<textarea
id="profile-voice"
v-model="store.candidate_voice"
class="text-area"
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
</div>
<div class="field-row">
<label class="field-label" for="profile-inference">Inference profile</label>
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
<option value="remote">Remote</option>
<option value="cpu">CPU</option>
<option value="single-gpu">Single GPU</option>
<option value="dual-gpu">Dual GPU</option>
</select>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Identity' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- Mission & Values -->
<section class="form-section">
<h3 class="section-title">Mission &amp; Values</h3>
<p class="section-desc">
Industries you care about. When a job matches, the cover letter includes your personal alignment note.
</p>
<div
v-for="(pref, idx) in store.mission_preferences"
:key="idx"
class="mission-row"
>
<input
v-model="pref.industry"
type="text"
class="text-input mission-industry"
placeholder="Industry (e.g. music)"
/>
<input
v-model="pref.note"
type="text"
class="text-input mission-note"
placeholder="Your personal note (optional)"
/>
<button class="btn-remove" type="button" @click="removeMission(idx)" aria-label="Remove">×</button>
</div>
<div class="mission-actions">
<button class="btn-secondary" type="button" @click="addMission">+ Add mission</button>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateMissions"
:disabled="generatingMissions"
>{{ generatingMissions ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Mission' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- NDA Companies -->
<section class="form-section">
<h3 class="section-title">NDA Companies</h3>
<p class="section-desc">
Companies you can't name. They appear as "previous employer (NDA)" in research briefs when match score is low.
</p>
<div class="tag-list">
<span
v-for="(company, idx) in store.nda_companies"
:key="idx"
class="tag"
>
{{ company }}
<button class="tag-remove" type="button" @click="removeNda(idx)" :aria-label="`Remove ${company}`">×</button>
</span>
</div>
<div class="nda-add-row">
<input
v-model="newNdaCompany"
type="text"
class="text-input nda-input"
placeholder="Company name"
@keydown.enter.prevent="addNda"
/>
<button class="btn-secondary" type="button" @click="addNda" :disabled="!newNdaCompany.trim()">Add</button>
</div>
</section>
<!-- ── Research Brief Preferences ────────────────── -->
<section class="form-section">
<h3 class="section-title">Research Brief Preferences</h3>
<p class="section-desc">
Optional sections added to company briefs — for your personal decision-making only.
These details are never included in applications.
</p>
<div class="checkbox-row">
<input
id="pref-accessibility"
v-model="store.accessibility_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-accessibility" class="checkbox-label">
Include accessibility &amp; inclusion research in company briefs
</label>
</div>
<div class="checkbox-row">
<input
id="pref-lgbtq"
v-model="store.lgbtq_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-lgbtq" class="checkbox-label">
Include LGBTQ+ inclusion research in company briefs
</label>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useProfileStore()
const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
onMounted(() => { store.load() })
// ── Mission helpers ──────────────────────────────────────
function addMission() {
store.mission_preferences = [...store.mission_preferences, { industry: '', note: '' }]
}
function removeMission(idx: number) {
store.mission_preferences = store.mission_preferences.filter((_, i) => i !== idx)
}
// ── NDA helpers (autosave on add/remove) ────────────────
function addNda() {
const val = newNdaCompany.value.trim()
if (!val) return
store.nda_companies = [...store.nda_companies, val]
newNdaCompany.value = ''
store.save()
}
function removeNda(idx: number) {
store.nda_companies = store.nda_companies.filter((_, i) => i !== idx)
store.save()
}
// ── Research prefs autosave (debounced 400ms) ────────────
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function autosave() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => store.save(), 400)
}
// ── AI generation (paid tier) ────────────────────────────
async function generateSummary() {
generatingSummary.value = true
try {
const res = await fetch('/api/settings/profile/generate-summary', { method: 'POST' })
if (res.ok) {
const data = await res.json() as { summary?: string }
if (data.summary) store.career_summary = data.summary
}
} finally {
generatingSummary.value = false
}
}
async function generateMissions() {
generatingMissions.value = true
try {
const res = await fetch('/api/settings/profile/generate-missions', { method: 'POST' })
if (res.ok) {
const data = await res.json() as { mission_preferences?: Array<{ industry: string; note: string }> }
if (data.mission_preferences) store.mission_preferences = data.mission_preferences
}
} finally {
generatingMissions.value = false
}
}
</script>
<style scoped>
.my-profile {
max-width: 680px;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h2 {
margin: 0 0 var(--space-1);
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.loading-state {
color: var(--color-text-muted);
font-size: 0.875rem;
padding: var(--space-4) 0;
}
/* ── Sections ──────────────────────────────────────────── */
.form-section {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.section-title {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.section-desc {
margin: calc(-1 * var(--space-2)) 0 var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* ── Fields ───────────────────────────────────────────── */
.field-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.field-row--stacked {
grid-template-columns: 1fr;
align-items: flex-start;
}
.field-row--stacked .field-label {
margin-bottom: var(--space-1);
}
.field-label {
font-size: 0.825rem;
color: var(--color-text-muted);
font-weight: 500;
}
.text-input,
.select-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
box-sizing: border-box;
}
.text-input:focus,
.select-input:focus,
.text-area:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: transparent;
}
.text-area {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
/* ── Save row ─────────────────────────────────────────── */
.save-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.btn-save {
padding: var(--space-2) var(--space-5);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
margin: 0;
color: var(--color-danger, #c0392b);
font-size: 0.825rem;
}
.btn-generate {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-primary);
color: var(--color-primary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
margin-top: var(--space-2);
align-self: flex-start;
}
.btn-generate:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mission rows ─────────────────────────────────────── */
.mission-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: var(--space-2);
margin-bottom: var(--space-2);
align-items: center;
}
.mission-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn-remove {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-remove:hover {
border-color: var(--color-danger, #c0392b);
color: var(--color-danger, #c0392b);
}
/* ── NDA tags ─────────────────────────────────────────── */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 32px;
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: 999px;
font-size: 0.8rem;
color: var(--color-text);
}
.tag-remove {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
}
.tag-remove:hover {
color: var(--color-danger, #c0392b);
}
.nda-add-row {
display: flex;
gap: var(--space-2);
}
.nda-input {
flex: 1;
}
/* ── Checkboxes ───────────────────────────────────────── */
.checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.checkbox {
flex-shrink: 0;
margin-top: 2px;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 767px) {
.field-row {
grid-template-columns: 1fr;
}
.mission-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
}
.mission-note {
grid-column: 1;
}
.btn-remove {
grid-row: 1;
grid-column: 2;
align-self: start;
}
}
</style>