peregrine/web/src/views/settings/SearchPrefsView.vue
pyr0ball 82c26074d8 fix: search prefs wizard data loss, resume sync link, docs + GUI help links
Bug fixes (filed as #125–#128):
- Wizard step 7 read data.titles instead of data.search.titles — user-entered
  job titles and locations were silently dropped on every wizard run (#125)
- GET /api/settings/search returned "titles" key but store expected "job_titles" —
  Settings → Search Prefs always showed empty even when data existed (#126)
- remote_only preference not persisted during wizard setup (#127)
- apply-to-profile didn't set default_resume_id in user.yaml, so future
  Resume Profile saves never synced back to the library entry (#128)

Also:
- Wizard step headings corrected (off-by-one after Training step was inserted)
- Ollama host in wizard inference step now reads from saved wizard state
- Resume upload during wizard now creates a library entry and sets it as default

Docs:
- New: docs/user-guide/daily-workflow.md — end-to-end daily usage guide
- Updated: docs/user-guide/settings.md — rewritten for Vue SPA (was Streamlit)
- mkdocs.yml nav: Daily Workflow added as first User Guide entry

GUI help links:
- web/src/composables/useDocsUrl.ts — shared docs base URL composable
- Home: "Daily Workflow guide ↗" link in subtitle
- Job Review: "? Docs" link in title row
- Resume Library: "? Help" link in header
- Settings → Resume Profile: "? Help" link in page header
- Settings → Search Prefs: "? Help" link in page header
2026-06-15 16:52:56 -07:00

271 lines
12 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="search-prefs">
<div class="page-header">
<h2>Search Preferences</h2>
<a :href="docsUrl" target="_blank" rel="noopener" class="help-link" aria-label="Search Preferences documentation">? Help</a>
</div>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- Remote Preference -->
<section class="form-section">
<h3>Remote Preference</h3>
<div class="remote-options">
<button
v-for="opt in remoteOptions"
:key="opt.value"
:class="['remote-btn', { active: store.remote_preference === opt.value }]"
@click="store.remote_preference = opt.value"
>{{ opt.label }}</button>
</div>
<p class="section-note">This filter runs at scrape time listings that don't match are excluded before they count against per-board quotas.</p>
</section>
<!-- Job Titles -->
<section class="form-section">
<h3>Job Titles</h3>
<div class="tags">
<span v-for="title in store.job_titles" :key="title" class="tag">
{{ title }} <button @click="store.removeTag('job_titles', title)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="titleInput" @keydown.enter.prevent="addTitle" placeholder="Add title, press Enter" />
<button @click="store.suggestTitles()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.titleSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.titleSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('title', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Locations -->
<section class="form-section">
<h3>Locations</h3>
<div class="tags">
<span v-for="loc in store.locations" :key="loc" class="tag">
{{ loc }} <button @click="store.removeTag('locations', loc)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="locationInput" @keydown.enter.prevent="addLocation" placeholder="Add location, press Enter" />
<button @click="store.suggestLocations()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.locationSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.locationSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('location', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Exclude Keywords -->
<section class="form-section">
<h3>Exclude Keywords</h3>
<div class="tags">
<span v-for="kw in store.exclude_keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
<button @click="store.suggestExcludeKeywords()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.excludeSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.excludeSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('exclude', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Job Boards -->
<section class="form-section">
<h3>Job Boards</h3>
<div v-for="board in store.job_boards" :key="board.name" class="board-row" :class="{ 'board-row--unsupported': board.supported === false }">
<label class="checkbox-row">
<input
type="checkbox"
:checked="board.enabled"
:disabled="board.supported === false"
@change="store.toggleBoard(board.name)"
/>
{{ board.name }}
<span v-if="board.supported === false" class="board-badge board-badge--pending" title="Not yet implemented — tracked in backlog">coming soon</span>
</label>
</div>
<div class="field-row" style="margin-top: 12px">
<label>Custom Board URLs</label>
<div class="tags">
<span v-for="url in store.custom_board_urls" :key="url" class="tag">
{{ url }} <button @click="store.removeTag('custom_board_urls', url)">×</button>
</span>
</div>
<input v-model="customUrlInput" @keydown.enter.prevent="store.addTag('custom_board_urls', customUrlInput); customUrlInput = ''" placeholder="https://..." />
</div>
</section>
<!-- Blocklists -->
<section class="form-section">
<h3>Blocklists</h3>
<div class="blocklist-group">
<label>Companies</label>
<div class="tags">
<span v-for="c in store.blocklist_companies" :key="c" class="tag">
{{ c }} <button @click="store.removeTag('blocklist_companies', c)">×</button>
</span>
</div>
<input v-model="blockCompanyInput" @keydown.enter.prevent="store.addTag('blocklist_companies', blockCompanyInput); blockCompanyInput = ''" placeholder="Company name" />
</div>
<div class="blocklist-group">
<label>Industries</label>
<div class="tags">
<span v-for="i in store.blocklist_industries" :key="i" class="tag">
{{ i }} <button @click="store.removeTag('blocklist_industries', i)">×</button>
</span>
</div>
<input v-model="blockIndustryInput" @keydown.enter.prevent="store.addTag('blocklist_industries', blockIndustryInput); blockIndustryInput = ''" placeholder="Industry name" />
</div>
<div class="blocklist-group">
<label>Locations</label>
<div class="tags">
<span v-for="l in store.blocklist_locations" :key="l" class="tag">
{{ l }} <button @click="store.removeTag('blocklist_locations', l)">×</button>
</span>
</div>
<input v-model="blockLocationInput" @keydown.enter.prevent="store.addTag('blocklist_locations', blockLocationInput); blockLocationInput = ''" placeholder="Location name" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving' : 'Save Search Preferences' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSearchStore } from '../../stores/settings/search'
import { useDocsUrl } from '../../composables/useDocsUrl'
const store = useSearchStore()
const docsUrl = useDocsUrl('user-guide/settings/#search-prefs')
const remoteOptions = [
{ value: 'remote' as const, label: 'Remote only' },
{ value: 'onsite' as const, label: 'On-site only' },
{ value: 'both' as const, label: 'Both' },
]
const titleInput = ref('')
const locationInput = ref('')
const excludeInput = ref('')
const customUrlInput = ref('')
const blockCompanyInput = ref('')
const blockIndustryInput = ref('')
const blockLocationInput = ref('')
function addTitle() {
store.addTag('job_titles', titleInput.value)
titleInput.value = ''
}
function addLocation() {
store.addTag('locations', locationInput.value)
locationInput.value = ''
}
onMounted(() => store.load())
</script>
<style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
.page-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
.page-header h2 { margin-bottom: 0; }
.help-link { font-size: 0.75rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 2px 8px; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.help-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
.remote-btn.active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-text-inverse); }
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-top: 8px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag {
padding: 3px 10px;
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: 12px; font-size: 0.78rem; color: var(--color-accent);
display: flex; align-items: center; gap: 5px;
}
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-input-row { display: flex; gap: 8px; }
.tag-input-row input, input[type="text"], input:not([type]) {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 6px; color: var(--color-text);
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box;
}
.btn-suggest {
padding: 7px 14px; border-radius: 6px;
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent); cursor: pointer; font-size: 0.82rem; white-space: nowrap;
}
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.suggestion-chip {
padding: 4px 12px; border-radius: 12px; font-size: 0.78rem;
background: var(--color-surface-alt);
border: 1px dashed var(--color-border);
color: var(--color-text-muted); cursor: pointer; transition: all 0.15s;
}
.suggestion-chip:hover {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent);
}
.board-row { margin-bottom: 8px; }
.board-row--unsupported { opacity: 0.5; }
.board-row--unsupported input[type="checkbox"] { cursor: not-allowed; }
.board-badge {
display: inline-block;
margin-left: var(--space-2);
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
vertical-align: middle;
}
.board-badge--pending {
background: var(--color-surface-alt);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text); cursor: pointer; }
.field-row { display: flex; flex-direction: column; gap: 6px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.blocklist-group { margin-bottom: var(--space-4); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6); display: flex; align-items: center; gap: var(--space-4); }
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px; color: var(--color-error); padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem;
}
.error { color: var(--color-error); font-size: 0.82rem; }
</style>