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
This commit is contained in:
parent
32a83d6ff4
commit
1ef418ba00
4 changed files with 800 additions and 1 deletions
109
dev-api.py
109
dev-api.py
|
|
@ -17,7 +17,7 @@ from pathlib import Path
|
|||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
import requests
|
||||
|
||||
# Allow importing peregrine scripts for cover letter generation
|
||||
|
|
@ -901,3 +901,110 @@ def config_user():
|
|||
return {"name": cfg.get("name", "")}
|
||||
except Exception:
|
||||
return {"name": ""}
|
||||
|
||||
|
||||
# ── Settings: My Profile endpoints ───────────────────────────────────────────
|
||||
|
||||
def _user_yaml_path() -> str:
|
||||
"""Resolve user.yaml path, falling back to legacy location."""
|
||||
cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml")
|
||||
if not os.path.exists(cfg_path):
|
||||
cfg_path = "/devl/job-seeker/config/user.yaml"
|
||||
return cfg_path
|
||||
|
||||
|
||||
def _read_user_yaml() -> dict:
|
||||
import yaml
|
||||
path = _user_yaml_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _write_user_yaml(data: dict) -> None:
|
||||
import yaml
|
||||
path = _user_yaml_path()
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def _mission_dict_to_list(prefs: object) -> list:
|
||||
"""Convert {industry: note} dict to [{industry, note}] list for the SPA."""
|
||||
if isinstance(prefs, list):
|
||||
return prefs
|
||||
if isinstance(prefs, dict):
|
||||
return [{"industry": k, "note": v or ""} for k, v in prefs.items()]
|
||||
return []
|
||||
|
||||
|
||||
def _mission_list_to_dict(prefs: list) -> dict:
|
||||
"""Convert [{industry, note}] list from the SPA back to {industry: note} dict."""
|
||||
result = {}
|
||||
for item in prefs:
|
||||
if isinstance(item, dict):
|
||||
result[item.get("industry", "")] = item.get("note", "")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/settings/profile")
|
||||
def get_profile():
|
||||
try:
|
||||
cfg = _read_user_yaml()
|
||||
return {
|
||||
"name": cfg.get("name", ""),
|
||||
"email": cfg.get("email", ""),
|
||||
"phone": cfg.get("phone", ""),
|
||||
"linkedin_url": cfg.get("linkedin", ""),
|
||||
"career_summary": cfg.get("career_summary", ""),
|
||||
"candidate_voice": cfg.get("candidate_voice", ""),
|
||||
"inference_profile": cfg.get("inference_profile", "cpu"),
|
||||
"mission_preferences": _mission_dict_to_list(cfg.get("mission_preferences", {})),
|
||||
"nda_companies": cfg.get("nda_companies", []),
|
||||
"accessibility_focus": cfg.get("candidate_accessibility_focus", False),
|
||||
"lgbtq_focus": cfg.get("candidate_lgbtq_focus", False),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Could not read profile: {e}")
|
||||
|
||||
|
||||
class MissionPrefModel(BaseModel):
|
||||
industry: str
|
||||
note: str = ""
|
||||
|
||||
|
||||
class UserProfilePayload(BaseModel):
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
phone: str = ""
|
||||
linkedin_url: str = ""
|
||||
career_summary: str = ""
|
||||
candidate_voice: str = ""
|
||||
inference_profile: str = "cpu"
|
||||
mission_preferences: List[MissionPrefModel] = []
|
||||
nda_companies: List[str] = []
|
||||
accessibility_focus: bool = False
|
||||
lgbtq_focus: bool = False
|
||||
|
||||
|
||||
@app.put("/api/settings/profile")
|
||||
def save_profile(payload: UserProfilePayload):
|
||||
try:
|
||||
cfg = _read_user_yaml()
|
||||
cfg["name"] = payload.name
|
||||
cfg["email"] = payload.email
|
||||
cfg["phone"] = payload.phone
|
||||
cfg["linkedin"] = payload.linkedin_url
|
||||
cfg["career_summary"] = payload.career_summary
|
||||
cfg["candidate_voice"] = payload.candidate_voice
|
||||
cfg["inference_profile"] = payload.inference_profile
|
||||
cfg["mission_preferences"] = _mission_list_to_dict(
|
||||
[m.model_dump() for m in payload.mission_preferences]
|
||||
)
|
||||
cfg["nda_companies"] = payload.nda_companies
|
||||
cfg["candidate_accessibility_focus"] = payload.accessibility_focus
|
||||
cfg["candidate_lgbtq_focus"] = payload.lgbtq_focus
|
||||
_write_user_yaml(cfg)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Could not save profile: {e}")
|
||||
|
|
|
|||
40
web/src/stores/settings/profile.test.ts
Normal file
40
web/src/stores/settings/profile.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useProfileStore } from './profile'
|
||||
|
||||
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
const mockFetch = vi.mocked(useApiFetch)
|
||||
|
||||
describe('useProfileStore', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||
|
||||
it('load() populates fields from API', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
data: { name: 'Meg', email: 'meg@example.com', phone: '555-0100',
|
||||
linkedin_url: '', career_summary: '', candidate_voice: '',
|
||||
inference_profile: 'cpu', mission_preferences: [],
|
||||
nda_companies: [], accessibility_focus: false, lgbtq_focus: false },
|
||||
error: null,
|
||||
})
|
||||
const store = useProfileStore()
|
||||
await store.load()
|
||||
expect(store.name).toBe('Meg')
|
||||
expect(store.email).toBe('meg@example.com')
|
||||
})
|
||||
|
||||
it('save() calls PUT /api/settings/profile', async () => {
|
||||
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||
const store = useProfileStore()
|
||||
store.name = 'Meg'
|
||||
await store.save()
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/settings/profile', expect.objectContaining({ method: 'PUT' }))
|
||||
})
|
||||
|
||||
it('save() error sets error state', async () => {
|
||||
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
|
||||
const store = useProfileStore()
|
||||
await store.save()
|
||||
expect(store.saveError).toBeTruthy()
|
||||
})
|
||||
})
|
||||
87
web/src/stores/settings/profile.ts
Normal file
87
web/src/stores/settings/profile.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
|
||||
export interface MissionPref { industry: string; note: string }
|
||||
|
||||
export const useProfileStore = defineStore('settings/profile', () => {
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const phone = ref('')
|
||||
const linkedin_url = ref('')
|
||||
const career_summary = ref('')
|
||||
const candidate_voice = ref('')
|
||||
const inference_profile = ref('cpu')
|
||||
const mission_preferences = ref<MissionPref[]>([])
|
||||
const nda_companies = ref<string[]>([])
|
||||
const accessibility_focus = ref(false)
|
||||
const lgbtq_focus = ref(false)
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/profile')
|
||||
loading.value = false
|
||||
if (!data) return
|
||||
name.value = String(data.name ?? '')
|
||||
email.value = String(data.email ?? '')
|
||||
phone.value = String(data.phone ?? '')
|
||||
linkedin_url.value = String(data.linkedin_url ?? '')
|
||||
career_summary.value = String(data.career_summary ?? '')
|
||||
candidate_voice.value = String(data.candidate_voice ?? '')
|
||||
inference_profile.value = String(data.inference_profile ?? 'cpu')
|
||||
mission_preferences.value = (data.mission_preferences as MissionPref[]) ?? []
|
||||
nda_companies.value = (data.nda_companies as string[]) ?? []
|
||||
accessibility_focus.value = Boolean(data.accessibility_focus)
|
||||
lgbtq_focus.value = Boolean(data.lgbtq_focus)
|
||||
}
|
||||
|
||||
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,
|
||||
career_summary: career_summary.value,
|
||||
candidate_voice: candidate_voice.value,
|
||||
inference_profile: inference_profile.value,
|
||||
mission_preferences: mission_preferences.value,
|
||||
nda_companies: nda_companies.value,
|
||||
accessibility_focus: accessibility_focus.value,
|
||||
lgbtq_focus: lgbtq_focus.value,
|
||||
}
|
||||
const { error } = await useApiFetch('/api/settings/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
saving.value = false
|
||||
if (error) {
|
||||
saveError.value = 'Save failed — please try again.'
|
||||
return
|
||||
}
|
||||
// Push identity fields to resume YAML — graceful; endpoint may not exist yet (Task 3)
|
||||
await useApiFetch('/api/settings/resume/sync-identity', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
phone: phone.value,
|
||||
linkedin_url: linkedin_url.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name, email, phone, linkedin_url, career_summary, candidate_voice, inference_profile,
|
||||
mission_preferences, nda_companies, accessibility_focus, lgbtq_focus,
|
||||
loading, saving, saveError,
|
||||
load, save,
|
||||
}
|
||||
})
|
||||
565
web/src/views/settings/MyProfileView.vue
Normal file
565
web/src/views/settings/MyProfileView.vue
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
<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="2–3 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 & 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 & 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>
|
||||
Loading…
Reference in a new issue