feat(settings): Search Prefs tab — store, view, API endpoints, remote preference filter
This commit is contained in:
parent
92bd82b4c9
commit
2200d05b5c
5 changed files with 423 additions and 1 deletions
46
dev-api.py
46
dev-api.py
|
|
@ -1093,3 +1093,49 @@ async def upload_resume(file: UploadFile):
|
||||||
return {"ok": True, "data": result}
|
return {"ok": True, "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings: Search Preferences endpoints ────────────────────────────────────
|
||||||
|
|
||||||
|
class SearchPrefsPayload(BaseModel):
|
||||||
|
remote_preference: str = "both"
|
||||||
|
job_titles: List[str] = []
|
||||||
|
locations: List[str] = []
|
||||||
|
exclude_keywords: List[str] = []
|
||||||
|
job_boards: List[dict] = []
|
||||||
|
custom_board_urls: List[str] = []
|
||||||
|
blocklist_companies: List[str] = []
|
||||||
|
blocklist_industries: List[str] = []
|
||||||
|
blocklist_locations: List[str] = []
|
||||||
|
|
||||||
|
SEARCH_PREFS_PATH = Path("config/search_profiles.yaml")
|
||||||
|
|
||||||
|
@app.get("/api/settings/search")
|
||||||
|
def get_search_prefs():
|
||||||
|
try:
|
||||||
|
if not SEARCH_PREFS_PATH.exists():
|
||||||
|
return {}
|
||||||
|
with open(SEARCH_PREFS_PATH) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return data.get("default", {})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.put("/api/settings/search")
|
||||||
|
def save_search_prefs(payload: SearchPrefsPayload):
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
if SEARCH_PREFS_PATH.exists():
|
||||||
|
with open(SEARCH_PREFS_PATH) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
data["default"] = payload.model_dump()
|
||||||
|
with open(SEARCH_PREFS_PATH, "w") as f:
|
||||||
|
yaml.dump(data, 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/search/suggest")
|
||||||
|
def suggest_search(body: dict):
|
||||||
|
# Stub — LLM suggest for paid tier
|
||||||
|
return {"suggestions": []}
|
||||||
|
|
|
||||||
|
|
@ -196,13 +196,20 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
|
||||||
exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])]
|
exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])]
|
||||||
results_per_board = profile.get("results_per_board", 25)
|
results_per_board = profile.get("results_per_board", 25)
|
||||||
|
|
||||||
|
# Map remote_preference → JobSpy is_remote param:
|
||||||
|
# 'remote' → True (remote-only listings)
|
||||||
|
# 'onsite' → False (on-site-only listings)
|
||||||
|
# 'both' → None (no filter — JobSpy default)
|
||||||
|
_rp = profile.get("remote_preference", "both")
|
||||||
|
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None)
|
||||||
|
|
||||||
for location in profile["locations"]:
|
for location in profile["locations"]:
|
||||||
|
|
||||||
# ── JobSpy boards ──────────────────────────────────────────────────
|
# ── JobSpy boards ──────────────────────────────────────────────────
|
||||||
if boards:
|
if boards:
|
||||||
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
|
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
|
||||||
try:
|
try:
|
||||||
jobs: pd.DataFrame = scrape_jobs(
|
jobspy_kwargs: dict = dict(
|
||||||
site_name=boards,
|
site_name=boards,
|
||||||
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
||||||
location=location,
|
location=location,
|
||||||
|
|
@ -210,6 +217,9 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
|
||||||
hours_old=profile.get("hours_old", 72),
|
hours_old=profile.get("hours_old", 72),
|
||||||
linkedin_fetch_description=True,
|
linkedin_fetch_description=True,
|
||||||
)
|
)
|
||||||
|
if _is_remote is not None:
|
||||||
|
jobspy_kwargs["is_remote"] = _is_remote
|
||||||
|
jobs: pd.DataFrame = scrape_jobs(**jobspy_kwargs)
|
||||||
print(f" [jobspy] {len(jobs)} raw results")
|
print(f" [jobspy] {len(jobs)} raw results")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" [jobspy] ERROR: {exc}")
|
print(f" [jobspy] ERROR: {exc}")
|
||||||
|
|
|
||||||
42
web/src/stores/settings/search.test.ts
Normal file
42
web/src/stores/settings/search.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useSearchStore } from './search'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useSearchStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('defaults remote_preference to both', () => {
|
||||||
|
expect(useSearchStore().remote_preference).toBe('both')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() sets fields from API', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: {
|
||||||
|
remote_preference: 'remote', job_titles: ['Engineer'], locations: ['NYC'],
|
||||||
|
exclude_keywords: [], job_boards: [], custom_board_urls: [],
|
||||||
|
blocklist_companies: [], blocklist_industries: [], blocklist_locations: [],
|
||||||
|
}, error: null })
|
||||||
|
const store = useSearchStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.remote_preference).toBe('remote')
|
||||||
|
expect(store.job_titles).toContain('Engineer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggest() adds to titleSuggestions without persisting', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { suggestions: ['Staff Engineer'] }, error: null })
|
||||||
|
const store = useSearchStore()
|
||||||
|
await store.suggestTitles()
|
||||||
|
expect(store.titleSuggestions).toContain('Staff Engineer')
|
||||||
|
expect(store.job_titles).not.toContain('Staff Engineer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save() calls PUT endpoint', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||||
|
const store = useSearchStore()
|
||||||
|
await store.save()
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/settings/search', expect.objectContaining({ method: 'PUT' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
120
web/src/stores/settings/search.ts
Normal file
120
web/src/stores/settings/search.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export type RemotePreference = 'remote' | 'onsite' | 'both'
|
||||||
|
export interface JobBoard { name: string; enabled: boolean }
|
||||||
|
|
||||||
|
export const useSearchStore = defineStore('settings/search', () => {
|
||||||
|
const remote_preference = ref<RemotePreference>('both')
|
||||||
|
const job_titles = ref<string[]>([])
|
||||||
|
const locations = ref<string[]>([])
|
||||||
|
const exclude_keywords = ref<string[]>([])
|
||||||
|
const job_boards = ref<JobBoard[]>([])
|
||||||
|
const custom_board_urls = ref<string[]>([])
|
||||||
|
const blocklist_companies = ref<string[]>([])
|
||||||
|
const blocklist_industries = ref<string[]>([])
|
||||||
|
const blocklist_locations = ref<string[]>([])
|
||||||
|
|
||||||
|
const titleSuggestions = ref<string[]>([])
|
||||||
|
const locationSuggestions = ref<string[]>([])
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/search')
|
||||||
|
loading.value = false
|
||||||
|
if (error) { loadError.value = 'Failed to load search preferences'; return }
|
||||||
|
if (!data) return
|
||||||
|
remote_preference.value = (data.remote_preference as RemotePreference) ?? 'both'
|
||||||
|
job_titles.value = (data.job_titles as string[]) ?? []
|
||||||
|
locations.value = (data.locations as string[]) ?? []
|
||||||
|
exclude_keywords.value = (data.exclude_keywords as string[]) ?? []
|
||||||
|
job_boards.value = (data.job_boards as JobBoard[]) ?? []
|
||||||
|
custom_board_urls.value = (data.custom_board_urls as string[]) ?? []
|
||||||
|
blocklist_companies.value = (data.blocklist_companies as string[]) ?? []
|
||||||
|
blocklist_industries.value = (data.blocklist_industries as string[]) ?? []
|
||||||
|
blocklist_locations.value = (data.blocklist_locations as string[]) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = null
|
||||||
|
const body = {
|
||||||
|
remote_preference: remote_preference.value,
|
||||||
|
job_titles: job_titles.value,
|
||||||
|
locations: locations.value,
|
||||||
|
exclude_keywords: exclude_keywords.value,
|
||||||
|
job_boards: job_boards.value,
|
||||||
|
custom_board_urls: custom_board_urls.value,
|
||||||
|
blocklist_companies: blocklist_companies.value,
|
||||||
|
blocklist_industries: blocklist_industries.value,
|
||||||
|
blocklist_locations: blocklist_locations.value,
|
||||||
|
}
|
||||||
|
const { error } = await useApiFetch('/api/settings/search', {
|
||||||
|
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 suggestTitles() {
|
||||||
|
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'titles', current: job_titles.value }),
|
||||||
|
})
|
||||||
|
if (data?.suggestions) {
|
||||||
|
titleSuggestions.value = data.suggestions.filter(s => !job_titles.value.includes(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestLocations() {
|
||||||
|
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'locations', current: locations.value }),
|
||||||
|
})
|
||||||
|
if (data?.suggestions) {
|
||||||
|
locationSuggestions.value = data.suggestions.filter(s => !locations.value.includes(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
|
||||||
|
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || arr.value.includes(trimmed)) return
|
||||||
|
arr.value.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
|
||||||
|
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
|
||||||
|
const idx = arr.value.indexOf(value)
|
||||||
|
if (idx !== -1) arr.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSuggestion(type: 'title' | 'location', value: string) {
|
||||||
|
if (type === 'title') {
|
||||||
|
if (!job_titles.value.includes(value)) job_titles.value.push(value)
|
||||||
|
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
|
||||||
|
} else {
|
||||||
|
if (!locations.value.includes(value)) locations.value.push(value)
|
||||||
|
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remote_preference, job_titles, locations, exclude_keywords, job_boards,
|
||||||
|
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
|
||||||
|
titleSuggestions, locationSuggestions,
|
||||||
|
loading, saving, saveError, loadError,
|
||||||
|
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion,
|
||||||
|
}
|
||||||
|
})
|
||||||
204
web/src/views/settings/SearchPrefsView.vue
Normal file
204
web/src/views/settings/SearchPrefsView.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<template>
|
||||||
|
<div class="search-prefs">
|
||||||
|
<h2>Search Preferences</h2>
|
||||||
|
<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>
|
||||||
|
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
|
||||||
|
</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">
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="board.enabled" />
|
||||||
|
{{ board.name }}
|
||||||
|
</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'
|
||||||
|
|
||||||
|
const store = useSearchStore()
|
||||||
|
|
||||||
|
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, 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)); }
|
||||||
|
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); background: transparent; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
|
||||||
|
.remote-btn.active { background: var(--color-accent, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; }
|
||||||
|
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-top: 8px; }
|
||||||
|
.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-input-row { display: flex; gap: 8px; }
|
||||||
|
.tag-input-row input, input[type="text"], input:not([type]) {
|
||||||
|
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.85rem; flex: 1; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.btn-suggest { padding: 7px 14px; border-radius: 6px; background: rgba(124,58,237,0.2); border: 1px solid rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); 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: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); color: var(--color-text-secondary, #94a3b8); cursor: pointer; transition: all 0.15s; }
|
||||||
|
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); }
|
||||||
|
.board-row { margin-bottom: 8px; }
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
|
||||||
|
.field-row { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.blocklist-group { margin-bottom: var(--space-4, 24px); }
|
||||||
|
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
|
||||||
|
.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-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
|
||||||
|
.error { color: #ef4444; font-size: 0.82rem; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue