feat(web): merge Vue SPA from feature/vue-spa; add ClassicUIButton + useFeatureFlag
- Import web/ directory (Vue 3 + Vite + UnoCSS SPA) from feature/vue-spa branch - Add web/src/components/ClassicUIButton.vue: switch-back to Streamlit via cookie (prgn_ui=streamlit) + ?prgn_switch=streamlit query param bridge - Add web/src/composables/useFeatureFlag.ts: reads prgn_demo_tier cookie for demo toolbar visual consistency (not an authoritative gate, see issue #8) - Update .gitignore: add .superpowers/, pytest-output.txt, docs/superpowers/
This commit is contained in:
parent
bc72c323d0
commit
8bd1be7b16
79 changed files with 18174 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,6 +35,9 @@ config/user.yaml.working
|
||||||
|
|
||||||
# Claude context files — kept out of version control
|
# Claude context files — kept out of version control
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
.superpowers/
|
||||||
|
pytest-output.txt
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
data/email_score.jsonl
|
data/email_score.jsonl
|
||||||
data/email_label_queue.jsonl
|
data/email_label_queue.jsonl
|
||||||
|
|
|
||||||
2
web/.gitignore
vendored
Normal file
2
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
20
web/index.html
Normal file
20
web/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Peregrine — Job Search Assistant</title>
|
||||||
|
<!-- Inline background prevents blank flash before CSS bundle loads -->
|
||||||
|
<!-- Matches --color-surface light / dark from theme.css -->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
|
||||||
|
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4966
web/package-lock.json
generated
Normal file
4966
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
web/package.json
Normal file
39
web/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "peregrine-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
||||||
|
"@fontsource/fraunces": "^5.2.9",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
|
"@vueuse/core": "^14.2.1",
|
||||||
|
"@vueuse/integrations": "^14.2.1",
|
||||||
|
"animejs": "^4.3.6",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@unocss/preset-attributify": "^66.6.4",
|
||||||
|
"@unocss/preset-wind": "^66.6.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"unocss": "^66.6.4",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
web/src/App.vue
Normal file
97
web/src/App.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<template>
|
||||||
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||||
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||||
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||||
|
<AppNav />
|
||||||
|
<main class="app-main" id="main-content" tabindex="-1">
|
||||||
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import { useMotion } from './composables/useMotion'
|
||||||
|
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||||
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useDigestStore } from './stores/digest'
|
||||||
|
|
||||||
|
const motion = useMotion()
|
||||||
|
const { toggle, restore } = useHackerMode()
|
||||||
|
const digestStore = useDigestStore()
|
||||||
|
|
||||||
|
useKonamiCode(toggle)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restore() // re-apply hacker mode from localStorage on hard reload
|
||||||
|
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global resets — unscoped, applied once to document */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body, sans-serif);
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
background: var(--color-surface, #eaeff8);
|
||||||
|
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app { min-height: 100dvh; }
|
||||||
|
|
||||||
|
/* Layout root — sidebar pushes content right on desktop */
|
||||||
|
.app-root {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* prevents flex blowout */
|
||||||
|
/* Desktop: offset by sidebar width */
|
||||||
|
margin-left: var(--sidebar-width, 220px);
|
||||||
|
/* Mobile: no sidebar, leave room for bottom tab bar */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip-to-content link — visible only on keyboard focus */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -999px;
|
||||||
|
left: var(--space-4);
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: white;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 9999;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: top 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: no sidebar margin, add bottom tab bar clearance */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-main {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
web/src/assets/peregrine.css
Normal file
116
web/src/assets/peregrine.css
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/* web/src/assets/peregrine.css
|
||||||
|
Peregrine-specific token overrides — imported AFTER theme.css.
|
||||||
|
Source of truth: circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md
|
||||||
|
|
||||||
|
Brand:
|
||||||
|
Falcon Blue (#2B6CB0) — slate-blue back plumage of the peregrine falcon
|
||||||
|
Talon Orange (#E06820) — vivid orange-yellow talons and cere
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Page-level ─────────────────────────────────────── */
|
||||||
|
html {
|
||||||
|
overscroll-behavior-x: none;
|
||||||
|
overflow-x: clip; /* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light mode (default) ──────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* ── Peregrine brand colors ── */
|
||||||
|
--app-primary: #2B6CB0; /* Falcon Blue — 4.70:1 on #eaeff8 ✅ AA */
|
||||||
|
--app-primary-hover: #245A9E;
|
||||||
|
--app-primary-light: #EBF4FF; /* subtle tint — background use only */
|
||||||
|
|
||||||
|
--app-accent: #E06820; /* Talon Orange — use dark text on top */
|
||||||
|
--app-accent-hover: #C05415;
|
||||||
|
--app-accent-light: #FFF3EB; /* subtle tint — background use only */
|
||||||
|
--app-accent-text: #1a2338; /* on-button text — dark navy, NEVER white (only 2.8:1) */
|
||||||
|
|
||||||
|
/* ── CSS variable aliases (component names → theme.css canonical names. Gotcha #5.) ── */
|
||||||
|
--color-bg: var(--color-surface);
|
||||||
|
--color-text-secondary: var(--color-text-muted);
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
--nav-height: 4rem; /* top bar height (mobile) */
|
||||||
|
--sidebar-width: 220px; /* persistent sidebar (≥1024px) */
|
||||||
|
|
||||||
|
/* ── Pipeline status colors ── */
|
||||||
|
/* Always pair with text label or icon — never color alone (accessibility) */
|
||||||
|
--status-pending: var(--color-warning);
|
||||||
|
--status-approve: var(--color-success);
|
||||||
|
--status-reject: var(--color-error);
|
||||||
|
--status-applied: var(--color-info);
|
||||||
|
--status-synced: #5b4fa8;
|
||||||
|
--status-archived: var(--color-text-muted);
|
||||||
|
--status-survey: #6d3fa8;
|
||||||
|
--status-phone: #1a7a6e;
|
||||||
|
--status-interview: var(--color-info);
|
||||||
|
--status-offer: #b8620a;
|
||||||
|
--status-hired: var(--color-success);
|
||||||
|
|
||||||
|
/* ── Match score thresholds ── */
|
||||||
|
--score-high: var(--color-success); /* ≥ 70% */
|
||||||
|
--score-mid-high: #2b7cb8; /* 50–69% — Falcon Blue variant */
|
||||||
|
--score-mid: var(--color-warning); /* 30–49% */
|
||||||
|
--score-low: var(--color-error); /* < 30% */
|
||||||
|
--score-none: var(--color-text-muted);
|
||||||
|
|
||||||
|
/* ── Motion tokens ── */
|
||||||
|
--swipe-exit: 300ms;
|
||||||
|
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--confetti-dur: 3500ms;
|
||||||
|
--transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--transition-dismiss: 350ms ease-in;
|
||||||
|
--transition-enter: 250ms ease-out;
|
||||||
|
|
||||||
|
/* ── Type scale ── */
|
||||||
|
--text-xs: 0.75rem; /* 12px — badge labels, timestamps, keyboard hints */
|
||||||
|
--text-sm: 0.875rem; /* 14px — card secondary, captions */
|
||||||
|
--text-base: 1rem; /* 16px — body, card descriptions */
|
||||||
|
--text-lg: 1.125rem; /* 18px — card title, section headers */
|
||||||
|
--text-xl: 1.25rem; /* 20px — page section headings */
|
||||||
|
--text-2xl: 1.5rem; /* 24px — page titles */
|
||||||
|
--text-3xl: 1.875rem; /* 30px — dashboard greeting */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode ─────────────────────────────────────── */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="hacker"]) {
|
||||||
|
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
|
||||||
|
--app-primary-hover: #7BBDE6;
|
||||||
|
--app-primary-light: #0D1F35;
|
||||||
|
|
||||||
|
--app-accent: #F6872A; /* Talon Orange (dark) — 5.22:1 on #16202e ✅ AA */
|
||||||
|
--app-accent-hover: #FF9840;
|
||||||
|
--app-accent-light: #2D1505;
|
||||||
|
--app-accent-text: #1a2338;
|
||||||
|
|
||||||
|
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
|
||||||
|
|
||||||
|
--status-synced: #9b8fea;
|
||||||
|
--status-survey: #b08fea;
|
||||||
|
--status-phone: #4ec9be;
|
||||||
|
--status-offer: #f5a43a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hacker mode (Konami easter egg) ──────────────── */
|
||||||
|
[data-theme="hacker"] {
|
||||||
|
--app-primary: #00ff41;
|
||||||
|
--app-primary-hover: #00cc33;
|
||||||
|
--app-primary-light: #001a00;
|
||||||
|
--app-accent: #00ff41;
|
||||||
|
--app-accent-hover: #00cc33;
|
||||||
|
--app-accent-light: #001a00;
|
||||||
|
--app-accent-text: #0a0c0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus style — keyboard nav (accessibility requirement) ── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
268
web/src/assets/theme.css
Normal file
268
web/src/assets/theme.css
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
/* assets/styles/theme.css — CENTRAL THEME FILE
|
||||||
|
Accessible Solarpunk: warm, earthy, humanist, trustworthy.
|
||||||
|
Hacker mode: terminal green circuit-trace dark (Konami code).
|
||||||
|
ALL color/font/spacing tokens live here — nowhere else.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Accessible Solarpunk — light (default) ──────── */
|
||||||
|
:root {
|
||||||
|
/* Brand */
|
||||||
|
--color-primary: #2d5a27;
|
||||||
|
--color-primary-hover: #234820;
|
||||||
|
--color-primary-light: #e8f2e7;
|
||||||
|
|
||||||
|
/* Surfaces — cool blue-slate, crisp and legible */
|
||||||
|
--color-surface: #eaeff8;
|
||||||
|
--color-surface-alt: #dde4f0;
|
||||||
|
--color-surface-raised: #f5f7fc;
|
||||||
|
|
||||||
|
/* Borders — cool blue-gray */
|
||||||
|
--color-border: #a8b8d0;
|
||||||
|
--color-border-light: #ccd5e6;
|
||||||
|
|
||||||
|
/* Text — dark navy, cool undertone */
|
||||||
|
--color-text: #1a2338;
|
||||||
|
--color-text-muted: #4a5c7a;
|
||||||
|
--color-text-inverse: #eaeff8;
|
||||||
|
|
||||||
|
/* Accent — amber/terracotta (action, links, CTAs) */
|
||||||
|
--color-accent: #c4732a;
|
||||||
|
--color-accent-hover: #a85c1f;
|
||||||
|
--color-accent-light: #fdf0e4;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-success: #3a7a32;
|
||||||
|
--color-error: #c0392b;
|
||||||
|
--color-warning: #d4891a;
|
||||||
|
--color-info: #1e6091;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-display: 'Fraunces', Georgia, serif; /* Headings — optical humanist serif */
|
||||||
|
--font-body: 'Atkinson Hyperlegible', system-ui, sans-serif; /* Body — designed for accessibility */
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, hacker mode */
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
--space-16: 4rem;
|
||||||
|
--space-24: 6rem;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows — cool blue-navy base */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition: 200ms ease;
|
||||||
|
--transition-slow: 400ms ease;
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
--header-height: 4rem;
|
||||||
|
--header-border: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accessible Solarpunk — dark (system dark mode) ─
|
||||||
|
Activates when OS/browser is in dark mode.
|
||||||
|
Uses :not([data-theme="hacker"]) so the Konami easter
|
||||||
|
egg always wins over the system preference. */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="hacker"]) {
|
||||||
|
/* Brand — lighter greens readable on dark surfaces */
|
||||||
|
--color-primary: #6ab870;
|
||||||
|
--color-primary-hover: #7ecb84;
|
||||||
|
--color-primary-light: #162616;
|
||||||
|
|
||||||
|
/* Surfaces — deep blue-slate, not pure black */
|
||||||
|
--color-surface: #16202e;
|
||||||
|
--color-surface-alt: #1e2a3a;
|
||||||
|
--color-surface-raised: #263547;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border: #2d4060;
|
||||||
|
--color-border-light: #233352;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text: #e4eaf5;
|
||||||
|
--color-text-muted: #8da0bc;
|
||||||
|
--color-text-inverse: #16202e;
|
||||||
|
|
||||||
|
/* Accent — lighter amber for dark bg contrast (WCAG AA) */
|
||||||
|
--color-accent: #e8a84a;
|
||||||
|
--color-accent-hover: #f5bc60;
|
||||||
|
--color-accent-light: #2d1e0a;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-success: #5eb85e;
|
||||||
|
--color-error: #e05252;
|
||||||
|
--color-warning: #e8a84a;
|
||||||
|
--color-info: #4da6e8;
|
||||||
|
|
||||||
|
/* Shadows — darker base for dark bg */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hacker/maker easter egg theme ──────────────── */
|
||||||
|
/* Activated by Konami code: ↑↑↓↓←→←→BA */
|
||||||
|
/* Stored in localStorage: 'cf-hacker-mode' */
|
||||||
|
/* Applied: document.documentElement.dataset.theme */
|
||||||
|
[data-theme="hacker"] {
|
||||||
|
--color-primary: #00ff41;
|
||||||
|
--color-primary-hover: #00cc33;
|
||||||
|
--color-primary-light: #001a00;
|
||||||
|
|
||||||
|
--color-surface: #0a0c0a;
|
||||||
|
--color-surface-alt: #0d120d;
|
||||||
|
--color-surface-raised: #111811;
|
||||||
|
|
||||||
|
--color-border: #1a3d1a;
|
||||||
|
--color-border-light: #123012;
|
||||||
|
|
||||||
|
--color-text: #b8f5b8;
|
||||||
|
--color-text-muted: #5a9a5a;
|
||||||
|
--color-text-inverse: #0a0c0a;
|
||||||
|
|
||||||
|
--color-accent: #00ff41;
|
||||||
|
--color-accent-hover: #00cc33;
|
||||||
|
--color-accent-light: #001a0a;
|
||||||
|
|
||||||
|
--color-success: #00ff41;
|
||||||
|
--color-error: #ff3333;
|
||||||
|
--color-warning: #ffaa00;
|
||||||
|
--color-info: #00aaff;
|
||||||
|
|
||||||
|
/* Hacker mode: mono font everywhere */
|
||||||
|
--font-display: 'JetBrains Mono', monospace;
|
||||||
|
--font-body: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 255, 65, 0.08);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 255, 65, 0.12);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(0, 255, 65, 0.15);
|
||||||
|
|
||||||
|
--header-border: 2px solid var(--color-border);
|
||||||
|
|
||||||
|
/* Hacker glow variants — for box-shadow, text-shadow, bg overlays */
|
||||||
|
--color-accent-glow-xs: rgba(0, 255, 65, 0.08);
|
||||||
|
--color-accent-glow-sm: rgba(0, 255, 65, 0.15);
|
||||||
|
--color-accent-glow-md: rgba(0, 255, 65, 0.4);
|
||||||
|
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base resets ─────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { margin: 0; min-height: 100vh; }
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible — keyboard nav — accessibility requirement */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prose — CMS rich text ───────────────────────── */
|
||||||
|
.prose {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-text);
|
||||||
|
max-width: 65ch;
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.prose h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.prose p { margin: 0 0 1rem; }
|
||||||
|
.prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.5rem; }
|
||||||
|
.prose li { margin-bottom: 0.4rem; }
|
||||||
|
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
.prose strong { font-weight: 700; }
|
||||||
|
.prose code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875em;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.5rem 0 0.5rem 1.25rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility: screen reader only ────────────────── */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.sr-only:focus-visible {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
clip: auto;
|
||||||
|
white-space: normal;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
318
web/src/components/AppNav.vue
Normal file
318
web/src/components/AppNav.vue
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
<template>
|
||||||
|
<!-- Desktop: persistent sidebar (≥1024px) -->
|
||||||
|
<!-- Mobile: bottom tab bar (<1024px) -->
|
||||||
|
<!-- Design spec: circuitforge-plans/peregrine/2026-03-03-nuxt-frontend-design.md §3.1 -->
|
||||||
|
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="sidebar__brand">
|
||||||
|
<RouterLink to="/" class="sidebar__logo" @click.prevent="handleLogoClick">
|
||||||
|
<span class="sidebar__bird" :class="{ 'sidebar__bird--ruffle': ruffling }" aria-hidden="true">🦅</span>
|
||||||
|
<span class="sidebar__wordmark">Peregrine</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav links -->
|
||||||
|
<ul class="sidebar__links" role="list">
|
||||||
|
<li v-for="link in navLinks" :key="link.to">
|
||||||
|
<RouterLink
|
||||||
|
:to="link.to"
|
||||||
|
class="sidebar__link"
|
||||||
|
active-class="sidebar__link--active"
|
||||||
|
:aria-label="link.label"
|
||||||
|
>
|
||||||
|
<component :is="link.icon" class="sidebar__icon" aria-hidden="true" />
|
||||||
|
<span class="sidebar__label">{{ link.label }}</span>
|
||||||
|
<span v-if="link.badge" class="sidebar__badge" :aria-label="`${link.badge} items`">{{ link.badge }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Hacker mode exit (shows when active) -->
|
||||||
|
<div v-if="isHackerMode" class="sidebar__hacker-exit">
|
||||||
|
<button class="sidebar__hacker-btn" @click="exitHackerMode">
|
||||||
|
Exit hacker mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings at bottom -->
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||||
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
|
<span class="sidebar__label">Settings</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile bottom tab bar -->
|
||||||
|
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<ul class="tabbar__links" role="list">
|
||||||
|
<li v-for="link in mobileLinks" :key="link.to">
|
||||||
|
<RouterLink
|
||||||
|
:to="link.to"
|
||||||
|
class="tabbar__link"
|
||||||
|
active-class="tabbar__link--active"
|
||||||
|
:aria-label="link.label"
|
||||||
|
>
|
||||||
|
<component :is="link.icon" class="tabbar__icon" aria-hidden="true" />
|
||||||
|
<span class="tabbar__label">{{ link.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
ClipboardDocumentListIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
LightBulbIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
NewspaperIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
|
import { useDigestStore } from '../stores/digest'
|
||||||
|
const digestStore = useDigestStore()
|
||||||
|
|
||||||
|
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
||||||
|
const logoClickCount = ref(0)
|
||||||
|
const ruffling = ref(false)
|
||||||
|
let clickTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function handleLogoClick() {
|
||||||
|
logoClickCount.value++
|
||||||
|
if (clickTimer) clearTimeout(clickTimer)
|
||||||
|
clickTimer = setTimeout(() => { logoClickCount.value = 0 }, 800)
|
||||||
|
|
||||||
|
if (logoClickCount.value >= 5) {
|
||||||
|
logoClickCount.value = 0
|
||||||
|
ruffling.value = true
|
||||||
|
setTimeout(() => { ruffling.value = false }, 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hacker mode state
|
||||||
|
const isHackerMode = computed(() =>
|
||||||
|
document.documentElement.dataset.theme === 'hacker',
|
||||||
|
)
|
||||||
|
|
||||||
|
function exitHackerMode() {
|
||||||
|
delete document.documentElement.dataset.theme
|
||||||
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = computed(() => [
|
||||||
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
|
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||||
|
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||||
|
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
|
||||||
|
badge: digestStore.entries.length || undefined },
|
||||||
|
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||||
|
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Mobile: only the 5 most-used views
|
||||||
|
const mobileLinks = [
|
||||||
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Review' },
|
||||||
|
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||||
|
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||||
|
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Sidebar (desktop ≥1024px) ──────────────────────── */
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
z-index: 100;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand {
|
||||||
|
padding: 0 var(--space-4) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click-the-bird ruffle animation — easter egg 9.6 */
|
||||||
|
.sidebar__bird {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
display: inline-block;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__bird--ruffle {
|
||||||
|
animation: bird-ruffle 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bird-ruffle {
|
||||||
|
0% { transform: rotate(0deg) scale(1); }
|
||||||
|
20% { transform: rotate(-8deg) scale(1.15); }
|
||||||
|
40% { transform: rotate(8deg) scale(1.2); }
|
||||||
|
60% { transform: rotate(-6deg) scale(1.1); }
|
||||||
|
80% { transform: rotate(4deg) scale(1.05); }
|
||||||
|
100% { transform: rotate(0deg) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__wordmark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__links {
|
||||||
|
flex: 1;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 44px; /* WCAG 2.5.5 touch target */
|
||||||
|
/* Enumerate properties explicitly — no transition:all. Gotcha #2. */
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link:hover {
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--active {
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--app-accent);
|
||||||
|
color: var(--app-accent-text);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hacker mode exit button */
|
||||||
|
.sidebar__hacker-exit {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__hacker-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--app-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__hacker-btn:hover {
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
padding: var(--space-3) var(--space-3) 0;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--footer {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||||
|
.app-tabbar {
|
||||||
|
display: none; /* hidden on desktop */
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
z-index: 100;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom); /* iPhone notch */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar__links {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar__link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
min-height: 56px; /* WCAG 2.5.5 touch target */
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar__link--active { color: var(--app-primary); }
|
||||||
|
.tabbar__icon { width: 1.5rem; height: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────── */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-sidebar { display: none; }
|
||||||
|
.app-tabbar { display: block; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
862
web/src/components/ApplyWorkspace.vue
Normal file
862
web/src/components/ApplyWorkspace.vue
Normal file
|
|
@ -0,0 +1,862 @@
|
||||||
|
<template>
|
||||||
|
<div class="workspace">
|
||||||
|
<div v-if="loadingJob" class="workspace__loading" aria-live="polite">
|
||||||
|
<span class="spinner" aria-hidden="true" />
|
||||||
|
<span>Loading job…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!job" class="workspace__not-found" role="alert">
|
||||||
|
<p>Job not found.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Two-panel layout: job details | cover letter -->
|
||||||
|
<div class="workspace__panels">
|
||||||
|
|
||||||
|
<!-- ── Left: Job details ──────────────────────────────────────── -->
|
||||||
|
<aside class="workspace__job-panel">
|
||||||
|
<div class="job-details">
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="job-details__badges">
|
||||||
|
<span
|
||||||
|
v-if="job.match_score !== null"
|
||||||
|
class="score-badge"
|
||||||
|
:class="[scoreBadgeClass, { 'score-badge--shimmer': shimmeringBadge }]"
|
||||||
|
>
|
||||||
|
{{ job.match_score }}%
|
||||||
|
</span>
|
||||||
|
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="job-details__title">{{ job.title }}</h1>
|
||||||
|
<div class="job-details__company">
|
||||||
|
{{ job.company }}
|
||||||
|
<span v-if="job.location" aria-hidden="true"> · </span>
|
||||||
|
<span v-if="job.location" class="job-details__location">{{ job.location }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="job.salary" class="job-details__salary">{{ job.salary }}</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
|
||||||
|
{{ job.description ?? 'No description available.' }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="(job.description?.length ?? 0) > 300"
|
||||||
|
class="expand-btn"
|
||||||
|
:aria-expanded="descExpanded"
|
||||||
|
@click="descExpanded = !descExpanded"
|
||||||
|
>
|
||||||
|
{{ descExpanded ? 'Show less ▲' : 'Show more ▼' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Keyword gaps -->
|
||||||
|
<div v-if="gaps.length > 0" class="job-details__gaps">
|
||||||
|
<span class="gaps-label">Missing keywords:</span>
|
||||||
|
<span v-for="kw in gaps.slice(0, 6)" :key="kw" class="gap-pill">{{ kw }}</span>
|
||||||
|
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
||||||
|
View listing ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── Right: Cover letter ────────────────────────────────────── -->
|
||||||
|
<main class="workspace__cl-panel">
|
||||||
|
<h2 class="cl-heading">Cover Letter</h2>
|
||||||
|
|
||||||
|
<!-- State: none — no draft yet -->
|
||||||
|
<template v-if="clState === 'none'">
|
||||||
|
<div class="cl-empty">
|
||||||
|
<p class="cl-empty__hint">No cover letter yet. Generate one with AI or paste your own.</p>
|
||||||
|
<div class="cl-empty__actions">
|
||||||
|
<button class="btn-generate" :disabled="generating" @click="generate()">
|
||||||
|
<span aria-hidden="true">✨</span> Generate with AI
|
||||||
|
</button>
|
||||||
|
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
|
||||||
|
Paste / write manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- State: queued / running — generating -->
|
||||||
|
<template v-else-if="clState === 'queued' || clState === 'running'">
|
||||||
|
<div class="cl-generating" role="status" aria-live="polite">
|
||||||
|
<span class="spinner spinner--lg" aria-hidden="true" />
|
||||||
|
<p class="cl-generating__label">
|
||||||
|
{{ clState === 'queued' ? 'Queued…' : (taskStage ?? 'Generating cover letter…') }}
|
||||||
|
</p>
|
||||||
|
<p class="cl-generating__hint">This usually takes 20–60 seconds</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- State: failed -->
|
||||||
|
<template v-else-if="clState === 'failed'">
|
||||||
|
<div class="cl-error" role="alert">
|
||||||
|
<span aria-hidden="true">⚠️</span>
|
||||||
|
<span class="cl-error__msg">Cover letter generation failed</span>
|
||||||
|
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
|
||||||
|
<button class="btn-generate" @click="generate()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- State: ready — editor -->
|
||||||
|
<template v-else-if="clState === 'ready'">
|
||||||
|
<div class="cl-editor">
|
||||||
|
<div class="cl-editor__toolbar">
|
||||||
|
<span class="cl-editor__wordcount" aria-live="polite">
|
||||||
|
{{ wordCount }} words
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-ghost btn-ghost--sm"
|
||||||
|
:disabled="isSaved || saving"
|
||||||
|
@click="saveCoverLetter"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving…' : (isSaved ? '✓ Saved' : 'Save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref="textareaEl"
|
||||||
|
v-model="clText"
|
||||||
|
class="cl-editor__textarea"
|
||||||
|
aria-label="Cover letter text"
|
||||||
|
placeholder="Your cover letter…"
|
||||||
|
@input="isSaved = false; autoResize()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download PDF -->
|
||||||
|
<button class="btn-download" :disabled="!clText.trim() || downloadingPdf" @click="downloadPdf">
|
||||||
|
<span aria-hidden="true">📄</span>
|
||||||
|
{{ downloadingPdf ? 'Generating PDF…' : 'Download PDF' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Regenerate button (when ready, offer to redo) -->
|
||||||
|
<button
|
||||||
|
v-if="clState === 'ready'"
|
||||||
|
class="btn-ghost btn-ghost--sm cl-regen"
|
||||||
|
@click="generate()"
|
||||||
|
>
|
||||||
|
↺ Regenerate
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||||
|
<div class="workspace__actions">
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--apply"
|
||||||
|
:disabled="actioning"
|
||||||
|
@click="markApplied"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🚀</span>
|
||||||
|
{{ actioning === 'apply' ? 'Marking…' : 'Mark as Applied' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--reject"
|
||||||
|
:disabled="!!actioning"
|
||||||
|
@click="rejectListing"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✗</span>
|
||||||
|
{{ actioning === 'reject' ? 'Rejecting…' : 'Reject Listing' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="toast" class="toast" role="status" aria-live="polite">{{ toast }}</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import type { Job } from '../stores/review'
|
||||||
|
|
||||||
|
const props = defineProps<{ jobId: number }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'job-removed': []
|
||||||
|
'cover-letter-generated': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Perfect Match — intentionally matches score-badge--high boundary (≥70%); update together
|
||||||
|
const PERFECT_MATCH_THRESHOLD = 70
|
||||||
|
const shimmeringBadge = ref(false)
|
||||||
|
|
||||||
|
// ─── Job ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const job = ref<Job | null>(null)
|
||||||
|
const loadingJob = ref(true)
|
||||||
|
const descExpanded = ref(false)
|
||||||
|
|
||||||
|
const gaps = computed<string[]>(() => {
|
||||||
|
if (!job.value?.keyword_gaps) return []
|
||||||
|
try { return JSON.parse(job.value.keyword_gaps) as string[] }
|
||||||
|
catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreBadgeClass = computed(() => {
|
||||||
|
const s = job.value?.match_score ?? 0
|
||||||
|
if (s >= 70) return 'score-badge--high'
|
||||||
|
if (s >= 50) return 'score-badge--mid-high'
|
||||||
|
if (s >= 30) return 'score-badge--mid'
|
||||||
|
return 'score-badge--low'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Cover letter state machine ───────────────────────────────────────────────
|
||||||
|
// none → queued → running → ready | failed
|
||||||
|
|
||||||
|
type ClState = 'none' | 'queued' | 'running' | 'ready' | 'failed'
|
||||||
|
|
||||||
|
const clState = ref<ClState>('none')
|
||||||
|
const clText = ref('')
|
||||||
|
const isSaved = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const generating = ref(false)
|
||||||
|
const taskStage = ref<string | null>(null)
|
||||||
|
const taskError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const wordCount = computed(() => {
|
||||||
|
const words = clText.value.trim().split(/\s+/).filter(Boolean)
|
||||||
|
return words.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Polling ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let pollTimer = 0
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = window.setInterval(pollTaskStatus, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTaskStatus() {
|
||||||
|
const { data } = await useApiFetch<{
|
||||||
|
status: string
|
||||||
|
stage: string | null
|
||||||
|
message: string | null
|
||||||
|
}>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
taskStage.value = data.stage
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
stopPolling()
|
||||||
|
// Re-fetch the job to get the new cover letter text
|
||||||
|
await fetchJob()
|
||||||
|
clState.value = 'ready'
|
||||||
|
generating.value = false
|
||||||
|
emit('cover-letter-generated')
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
stopPolling()
|
||||||
|
clState.value = 'failed'
|
||||||
|
taskError.value = data.message ?? 'Unknown error'
|
||||||
|
generating.value = false
|
||||||
|
} else {
|
||||||
|
clState.value = data.status === 'queued' ? 'queued' : 'running'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (generating.value) return
|
||||||
|
generating.value = true
|
||||||
|
clState.value = 'queued'
|
||||||
|
taskError.value = null
|
||||||
|
|
||||||
|
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter/generate`, { method: 'POST' })
|
||||||
|
if (error) {
|
||||||
|
clState.value = 'failed'
|
||||||
|
taskError.value = error.kind === 'http' ? error.detail : 'Network error'
|
||||||
|
generating.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCoverLetter() {
|
||||||
|
saving.value = true
|
||||||
|
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: clText.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (error) {
|
||||||
|
showToast('Save failed — please try again')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaved.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PDF download ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const downloadingPdf = ref(false)
|
||||||
|
|
||||||
|
async function downloadPdf() {
|
||||||
|
if (!job.value) return
|
||||||
|
downloadingPdf.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jobs/${props.jobId}/cover_letter/pdf`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
const company = job.value.company.replace(/[^a-zA-Z0-9]/g, '') || 'Company'
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10)
|
||||||
|
a.href = url
|
||||||
|
a.download = `CoverLetter_${company}_${dateStr}.pdf`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
showToast('PDF generation failed — save first and try again')
|
||||||
|
} finally {
|
||||||
|
downloadingPdf.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mark applied / reject ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const actioning = ref<'apply' | 'reject' | null>(null)
|
||||||
|
|
||||||
|
async function markApplied() {
|
||||||
|
if (actioning.value) return
|
||||||
|
actioning.value = 'apply'
|
||||||
|
if (!isSaved.value) await saveCoverLetter()
|
||||||
|
await useApiFetch(`/api/jobs/${props.jobId}/applied`, { method: 'POST' })
|
||||||
|
actioning.value = null
|
||||||
|
showToast('Marked as applied ✓')
|
||||||
|
setTimeout(() => emit('job-removed'), 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectListing() {
|
||||||
|
if (actioning.value) return
|
||||||
|
actioning.value = 'reject'
|
||||||
|
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
|
||||||
|
actioning.value = null
|
||||||
|
showToast('Listing rejected')
|
||||||
|
setTimeout(() => emit('job-removed'), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toast = ref<string | null>(null)
|
||||||
|
let toastTimer = 0
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toast.value = msg
|
||||||
|
toastTimer = window.setTimeout(() => { toast.value = null }, 3500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auto-resize textarea ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaEl.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(clText, () => nextTick(autoResize))
|
||||||
|
|
||||||
|
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchJob() {
|
||||||
|
const { data } = await useApiFetch<Job>(`/api/jobs/${props.jobId}`)
|
||||||
|
if (data) {
|
||||||
|
job.value = data
|
||||||
|
if (data.cover_letter) {
|
||||||
|
clText.value = data.cover_letter as string
|
||||||
|
clState.value = 'ready'
|
||||||
|
isSaved.value = true
|
||||||
|
}
|
||||||
|
if ((data.match_score ?? 0) >= PERFECT_MATCH_THRESHOLD) {
|
||||||
|
shimmeringBadge.value = false
|
||||||
|
nextTick(() => { shimmeringBadge.value = true })
|
||||||
|
setTimeout(() => { shimmeringBadge.value = false }, 850)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchJob()
|
||||||
|
loadingJob.value = false
|
||||||
|
|
||||||
|
// Check if a generation task is already in flight
|
||||||
|
if (clState.value === 'none') {
|
||||||
|
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
||||||
|
if (data && (data.status === 'queued' || data.status === 'running')) {
|
||||||
|
clState.value = data.status as ClState
|
||||||
|
taskStage.value = data.stage
|
||||||
|
generating.value = true
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick(autoResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extra type to allow cover_letter field on Job
|
||||||
|
declare module '../stores/review' {
|
||||||
|
interface Job { cover_letter?: string | null }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6) var(--space-6) var(--space-12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__loading,
|
||||||
|
.workspace__not-found {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-16);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two-panel layout ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.workspace__panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.3fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Job details panel ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.workspace__job-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
|
|
||||||
|
.job-details__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__company {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__location { font-weight: 400; }
|
||||||
|
|
||||||
|
.job-details__salary {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__desc--clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 6;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details__gaps { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; }
|
||||||
|
.gaps-label { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 600; }
|
||||||
|
.gap-pill {
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.gaps-more { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.job-details__link {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.job-details__link:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ── Cover letter panel ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.workspace__cl-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.cl-empty {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-empty__hint { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 36ch; }
|
||||||
|
|
||||||
|
.cl-empty__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; max-width: 260px; }
|
||||||
|
|
||||||
|
/* Generating state */
|
||||||
|
.cl-generating {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-10);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-generating__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-generating__hint { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.cl-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: rgba(192, 57, 43, 0.06);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-error__msg { font-weight: 700; }
|
||||||
|
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
/* Editor */
|
||||||
|
.cl-editor {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-editor__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-editor__wordcount {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-editor__textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.7;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-editor__textarea:focus { outline: none; }
|
||||||
|
|
||||||
|
.cl-regen {
|
||||||
|
align-self: flex-end;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download button */
|
||||||
|
.btn-download {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease;
|
||||||
|
min-height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover:not(:disabled) { background: var(--app-primary-light); border-color: var(--app-primary); }
|
||||||
|
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Generate button */
|
||||||
|
.btn-generate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background: var(--app-accent);
|
||||||
|
color: var(--app-accent-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover); }
|
||||||
|
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Action bar ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.workspace__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
min-height: 48px;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.action-btn--apply {
|
||||||
|
background: rgba(39, 174, 96, 0.10);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.action-btn--apply:hover:not(:disabled) { background: rgba(39, 174, 96, 0.20); }
|
||||||
|
|
||||||
|
.action-btn--reject {
|
||||||
|
background: rgba(192, 57, 43, 0.08);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.action-btn--reject:hover:not(:disabled) { background: rgba(192, 57, 43, 0.16); }
|
||||||
|
|
||||||
|
/* ── Shared badges ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
|
||||||
|
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
|
||||||
|
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
|
||||||
|
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
|
||||||
|
|
||||||
|
/* Perfect Match shimmer — fires once when a ≥70% job opens */
|
||||||
|
@keyframes shimmer-badge {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
|
||||||
|
30% { box-shadow: 0 0 8px 3px rgba(212, 175, 55, 0.6); background: rgba(212, 175, 55, 0.2); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
|
||||||
|
}
|
||||||
|
.score-badge--shimmer { animation: shimmer-badge 850ms ease-out forwards; }
|
||||||
|
|
||||||
|
.remote-badge {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ghost button ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 36px;
|
||||||
|
transition: background 150ms ease, color 150ms ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
|
||||||
|
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-ghost--sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); min-height: 28px; }
|
||||||
|
|
||||||
|
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--lg { width: 2rem; height: 2rem; border-width: 3px; }
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Toast ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-6);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 300;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
||||||
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workspace__panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__job-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-editor__textarea { min-height: 260px; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
left: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
transform: none;
|
||||||
|
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-3));
|
||||||
|
}
|
||||||
|
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.workspace { padding: var(--space-4); }
|
||||||
|
.workspace__actions { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
48
web/src/components/ClassicUIButton.vue
Normal file
48
web/src/components/ClassicUIButton.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="classic-ui-btn"
|
||||||
|
:title="label"
|
||||||
|
@click="switchToClassic"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
label?: string
|
||||||
|
}>(), {
|
||||||
|
label: 'Switch to Classic UI',
|
||||||
|
})
|
||||||
|
|
||||||
|
function switchToClassic(): void {
|
||||||
|
// Set cookie so Caddy routes next request to Streamlit
|
||||||
|
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
|
||||||
|
|
||||||
|
// Append ?prgn_switch=streamlit so Streamlit's sync_ui_cookie()
|
||||||
|
// updates user.yaml to match — cookie alone can't be read server-side
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('prgn_switch', 'streamlit')
|
||||||
|
window.location.href = url.toString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.classic-ui-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #444);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted, #aaa);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.classic-ui-btn:hover {
|
||||||
|
color: var(--color-text, #eee);
|
||||||
|
border-color: var(--color-text, #eee);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
426
web/src/components/InterviewCard.vue
Normal file
426
web/src/components/InterviewCard.vue
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { PipelineJob } from '../stores/interviews'
|
||||||
|
import type { StageSignal, PipelineStage } from '../stores/interviews'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
job: PipelineJob
|
||||||
|
focused?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
move: [jobId: number, preSelectedStage?: PipelineStage]
|
||||||
|
prep: [jobId: number]
|
||||||
|
survey: [jobId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Signal state
|
||||||
|
const sigExpanded = ref(false)
|
||||||
|
|
||||||
|
interface SignalMeta {
|
||||||
|
label: string
|
||||||
|
stage: PipelineStage
|
||||||
|
color: 'amber' | 'green' | 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_META: Record<StageSignal['stage_signal'], SignalMeta> = {
|
||||||
|
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
|
||||||
|
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
|
||||||
|
offer_received: { label: 'Move to Offer', stage: 'offer', color: 'green' },
|
||||||
|
survey_received: { label: 'Move to Survey', stage: 'survey', color: 'amber' },
|
||||||
|
rejected: { label: 'Mark Rejected', stage: 'interview_rejected', color: 'red' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_BG: Record<'amber' | 'green' | 'red', string> = {
|
||||||
|
amber: 'rgba(245,158,11,0.08)',
|
||||||
|
green: 'rgba(39,174,96,0.08)',
|
||||||
|
red: 'rgba(192,57,43,0.08)',
|
||||||
|
}
|
||||||
|
const COLOR_BORDER: Record<'amber' | 'green' | 'red', string> = {
|
||||||
|
amber: 'rgba(245,158,11,0.4)',
|
||||||
|
green: 'rgba(39,174,96,0.4)',
|
||||||
|
red: 'rgba(192,57,43,0.4)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleSignals(): StageSignal[] {
|
||||||
|
const sigs = props.job.stage_signals ?? []
|
||||||
|
return sigExpanded.value ? sigs : sigs.slice(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSignal(sig: StageSignal) {
|
||||||
|
// Optimistic removal
|
||||||
|
const arr = props.job.stage_signals
|
||||||
|
const idx = arr.findIndex(s => s.id === sig.id)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedSignalIds = ref(new Set<number>())
|
||||||
|
function toggleBodyExpand(sigId: number) {
|
||||||
|
const next = new Set(expandedSignalIds.value)
|
||||||
|
if (next.has(sigId)) next.delete(sigId)
|
||||||
|
else next.add(sigId)
|
||||||
|
expandedSignalIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-classify chips — neutral/unrelated/digest trigger two-call dismiss path
|
||||||
|
const RECLASSIFY_CHIPS = [
|
||||||
|
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
|
||||||
|
{ label: '✅ Positive', value: 'positive_response' as const },
|
||||||
|
{ label: '🟢 Offer', value: 'offer_received' as const },
|
||||||
|
{ label: '📋 Survey', value: 'survey_received' as const },
|
||||||
|
{ label: '✖ Rejected', value: 'rejected' as const },
|
||||||
|
{ label: '🚫 Unrelated', value: 'unrelated' },
|
||||||
|
{ label: '📰 Digest', value: 'digest' },
|
||||||
|
{ label: '— Neutral', value: 'neutral' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
|
||||||
|
|
||||||
|
async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
|
||||||
|
if (DISMISS_LABELS.has(newLabel)) {
|
||||||
|
// Optimistic removal
|
||||||
|
const arr = props.job.stage_signals
|
||||||
|
const idx = arr.findIndex(s => s.id === sig.id)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
// Two-call path: persist label (Avocet training hook) then dismiss
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
|
})
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
|
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
|
||||||
|
if (newLabel === 'digest') {
|
||||||
|
void useApiFetch('/api/digest-queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ job_contact_id: sig.id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prev = sig.stage_signal
|
||||||
|
sig.stage_signal = newLabel
|
||||||
|
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
|
})
|
||||||
|
if (error) sig.stage_signal = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreClass = computed(() => {
|
||||||
|
const s = (props.job.match_score ?? 0) * 100
|
||||||
|
if (s >= 85) return 'score--high'
|
||||||
|
if (s >= 65) return 'score--mid'
|
||||||
|
return 'score--low'
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreLabel = computed(() =>
|
||||||
|
props.job.match_score != null
|
||||||
|
? `${Math.round(props.job.match_score * 100)}%`
|
||||||
|
: '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
const interviewDateLabel = computed(() => {
|
||||||
|
if (!props.job.interview_date) return null
|
||||||
|
const d = new Date(props.job.interview_date)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.round((d.getTime() - now.getTime()) / 86400000)
|
||||||
|
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
if (diffDays === 0) return `Today ${timeStr}`
|
||||||
|
if (diffDays === 1) return `Tomorrow ${timeStr}`
|
||||||
|
if (diffDays === -1) return `Yesterday ${timeStr}`
|
||||||
|
if (diffDays > 1 && diffDays < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateChipIcon = computed(() => {
|
||||||
|
if (!props.job.interview_date) return ''
|
||||||
|
const map: Record<string, string> = { phone_screen: '📞', interviewing: '🎯', offer: '📜' }
|
||||||
|
return map[props.job.status] ?? '📅'
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnColor = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
phone_screen: 'var(--status-phone)',
|
||||||
|
interviewing: 'var(--color-info)',
|
||||||
|
offer: 'var(--status-offer)',
|
||||||
|
hired: 'var(--color-success)',
|
||||||
|
}
|
||||||
|
return map[props.job.status] ?? 'var(--color-border)'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="interview-card"
|
||||||
|
:class="{ 'interview-card--focused': focused }"
|
||||||
|
:style="{ '--card-accent': columnColor }"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="`${job.title} at ${job.company}`"
|
||||||
|
@keydown.enter="emit('prep', job.id)"
|
||||||
|
@keydown.m.exact="emit('move', job.id)"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">{{ job.title }}</div>
|
||||||
|
<div class="card-company">
|
||||||
|
{{ job.company }}
|
||||||
|
<span v-if="job.salary" class="card-salary">· {{ job.salary }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-badges">
|
||||||
|
<span class="score-badge" :class="scoreClass">{{ scoreLabel }}</span>
|
||||||
|
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="interviewDateLabel" class="date-chip">
|
||||||
|
{{ dateChipIcon }} {{ interviewDateLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="card-footer">
|
||||||
|
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
||||||
|
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>
|
||||||
|
<button
|
||||||
|
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
|
||||||
|
class="card-action"
|
||||||
|
@click.stop="emit('survey', job.id)"
|
||||||
|
>Survey →</button>
|
||||||
|
</footer>
|
||||||
|
<!-- Signal banners -->
|
||||||
|
<template v-if="job.stage_signals?.length">
|
||||||
|
<div
|
||||||
|
v-for="sig in visibleSignals()"
|
||||||
|
:key="sig.id"
|
||||||
|
class="signal-banner"
|
||||||
|
:style="{
|
||||||
|
background: COLOR_BG[SIGNAL_META[sig.stage_signal].color],
|
||||||
|
borderTopColor: COLOR_BORDER[SIGNAL_META[sig.stage_signal].color],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="signal-header">
|
||||||
|
<span class="signal-label">
|
||||||
|
📧 <strong>{{ SIGNAL_META[sig.stage_signal].label.replace('Move to ', '') }}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
|
||||||
|
<div class="signal-header-actions">
|
||||||
|
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)">
|
||||||
|
{{ expandedSignalIds.has(sig.id) ? '▾ Hide' : '▸ Read' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-signal-move"
|
||||||
|
@click.stop="emit('move', props.job.id, SIGNAL_META[sig.stage_signal].stage)"
|
||||||
|
:aria-label="`${SIGNAL_META[sig.stage_signal].label} for ${props.job.title}`"
|
||||||
|
>→ Move</button>
|
||||||
|
<button
|
||||||
|
class="btn-signal-dismiss"
|
||||||
|
@click.stop="dismissSignal(sig)"
|
||||||
|
aria-label="Dismiss signal"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded body + reclassify chips -->
|
||||||
|
<div v-if="expandedSignalIds.has(sig.id)" class="signal-body-expanded">
|
||||||
|
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
|
||||||
|
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
|
||||||
|
<div v-else class="signal-body-empty">No email body available.</div>
|
||||||
|
<div class="signal-reclassify">
|
||||||
|
<span class="signal-reclassify-label">Re-classify:</span>
|
||||||
|
<button
|
||||||
|
v-for="chip in RECLASSIFY_CHIPS"
|
||||||
|
:key="chip.value"
|
||||||
|
class="btn-chip"
|
||||||
|
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
|
||||||
|
@click.stop="reclassifySignal(sig, chip.value)"
|
||||||
|
>{{ chip.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="(job.stage_signals?.length ?? 0) > 1"
|
||||||
|
class="btn-sig-expand"
|
||||||
|
@click.stop="sigExpanded = !sigExpanded"
|
||||||
|
>{{ sigExpanded ? '− less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
|
||||||
|
</template>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.interview-card {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid var(--card-accent, var(--color-border));
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: box-shadow 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interview-card--focused,
|
||||||
|
.interview-card:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px var(--card-accent, var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-company {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-salary {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score--high {
|
||||||
|
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface-raised));
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score--mid {
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface-raised));
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score--low {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 18%, var(--color-surface-raised));
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-badge {
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: color-mix(in srgb, var(--color-info) 14%, var(--color-surface-raised));
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
|
||||||
|
color: var(--color-info);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 2px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-info);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-banner {
|
||||||
|
border-top: 1px solid transparent; /* color set inline */
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.signal-label { font-size: 0.82em; }
|
||||||
|
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||||
|
.btn-signal-move {
|
||||||
|
background: var(--color-primary); color: #fff;
|
||||||
|
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-signal-dismiss {
|
||||||
|
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.btn-signal-read {
|
||||||
|
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
|
||||||
|
cursor: pointer; padding: 2px 6px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.signal-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.signal-header-actions {
|
||||||
|
margin-left: auto; display: flex; gap: 6px; align-items: center;
|
||||||
|
}
|
||||||
|
.signal-body-expanded {
|
||||||
|
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.signal-from {
|
||||||
|
color: var(--color-text-muted); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.signal-body-text {
|
||||||
|
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.signal-body-empty {
|
||||||
|
color: var(--color-text-muted); font-style: italic;
|
||||||
|
}
|
||||||
|
.signal-reclassify {
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.signal-reclassify-label {
|
||||||
|
font-size: 0.75em; color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.btn-chip {
|
||||||
|
background: var(--color-surface); color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border); border-radius: 4px;
|
||||||
|
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-chip:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
.btn-chip-active {
|
||||||
|
background: var(--color-primary-muted, #e8f0ff);
|
||||||
|
color: var(--color-primary); border-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-sig-expand {
|
||||||
|
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
|
||||||
|
padding: 4px 12px; text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
282
web/src/components/JobCard.vue
Normal file
282
web/src/components/JobCard.vue
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="job-card"
|
||||||
|
:class="{
|
||||||
|
'job-card--expanded': expanded,
|
||||||
|
'job-card--shimmer': isPerfectMatch,
|
||||||
|
}"
|
||||||
|
:aria-label="`${job.title} at ${job.company}`"
|
||||||
|
>
|
||||||
|
<!-- Score badge + remote badge -->
|
||||||
|
<div class="job-card__badges">
|
||||||
|
<span
|
||||||
|
v-if="job.match_score !== null"
|
||||||
|
class="score-badge"
|
||||||
|
:class="scoreBadgeClass"
|
||||||
|
:aria-label="`${job.match_score}% match`"
|
||||||
|
>
|
||||||
|
{{ job.match_score }}%
|
||||||
|
</span>
|
||||||
|
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title + company -->
|
||||||
|
<h2 class="job-card__title">{{ job.title }}</h2>
|
||||||
|
<div class="job-card__company">
|
||||||
|
<span>{{ job.company }}</span>
|
||||||
|
<span v-if="job.location" class="job-card__sep" aria-hidden="true"> · </span>
|
||||||
|
<span v-if="job.location" class="job-card__location">{{ job.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Salary -->
|
||||||
|
<div v-if="job.salary" class="job-card__salary">{{ job.salary }}</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="job-card__desc" :class="{ 'job-card__desc--clamped': !expanded }">
|
||||||
|
{{ descriptionText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand/collapse -->
|
||||||
|
<button
|
||||||
|
v-if="job.description && job.description.length > DESC_LIMIT"
|
||||||
|
class="job-card__expand-btn"
|
||||||
|
:aria-expanded="expanded"
|
||||||
|
@click.stop="$emit(expanded ? 'collapse' : 'expand')"
|
||||||
|
>
|
||||||
|
{{ expanded ? 'Show less ▲' : 'Show more ▼' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Keyword gaps -->
|
||||||
|
<div v-if="gaps.length > 0" class="job-card__gaps">
|
||||||
|
<span class="job-card__gaps-label">Missing keywords:</span>
|
||||||
|
<span v-for="kw in gaps.slice(0, 5)" :key="kw" class="gap-pill">{{ kw }}</span>
|
||||||
|
<span v-if="gaps.length > 5" class="job-card__gaps-more">+{{ gaps.length - 5 }} more</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: source + date -->
|
||||||
|
<div class="job-card__footer">
|
||||||
|
<a
|
||||||
|
v-if="job.url"
|
||||||
|
:href="job.url"
|
||||||
|
class="job-card__url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
@click.stop
|
||||||
|
>View listing ↗</a>
|
||||||
|
<span class="job-card__date">{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Job } from '../stores/review'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
job: Job
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ expand: []; collapse: [] }>()
|
||||||
|
|
||||||
|
const DESC_LIMIT = 300
|
||||||
|
|
||||||
|
const isPerfectMatch = computed(() => (props.job.match_score ?? 0) >= 95)
|
||||||
|
|
||||||
|
const scoreBadgeClass = computed(() => {
|
||||||
|
const s = props.job.match_score ?? 0
|
||||||
|
if (s >= 80) return 'score-badge--high'
|
||||||
|
if (s >= 60) return 'score-badge--mid'
|
||||||
|
return 'score-badge--low'
|
||||||
|
})
|
||||||
|
|
||||||
|
const gaps = computed<string[]>(() => {
|
||||||
|
if (!props.job.keyword_gaps) return []
|
||||||
|
try { return JSON.parse(props.job.keyword_gaps) as string[] }
|
||||||
|
catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const descriptionText = computed(() => {
|
||||||
|
const d = props.job.description ?? ''
|
||||||
|
return !props.expanded && d.length > DESC_LIMIT
|
||||||
|
? d.slice(0, DESC_LIMIT) + '…'
|
||||||
|
: d
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.job.date_found) return ''
|
||||||
|
const d = new Date(props.job.date_found)
|
||||||
|
const days = Math.floor((Date.now() - d.getTime()) / 86400000)
|
||||||
|
if (days === 0) return 'Today'
|
||||||
|
if (days === 1) return 'Yesterday'
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.job-card {
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-card, 1rem);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Perfect match shimmer — easter egg 9.4 */
|
||||||
|
.job-card--shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
var(--color-surface-raised) 30%,
|
||||||
|
rgba(251, 210, 60, 0.25) 50%,
|
||||||
|
var(--color-surface-raised) 70%
|
||||||
|
);
|
||||||
|
background-size: 300% auto;
|
||||||
|
animation: shimmer-sweep 1.8s ease 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer-sweep {
|
||||||
|
0% { background-position: 100% center; }
|
||||||
|
100% { background-position: -100% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.job-card--shimmer { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
|
||||||
|
.score-badge--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
|
||||||
|
.score-badge--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
|
||||||
|
|
||||||
|
.remote-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__company {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__sep { color: var(--color-border); }
|
||||||
|
.job-card__location { font-weight: 400; }
|
||||||
|
|
||||||
|
.job-card__salary {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__desc--clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__expand-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__expand-btn:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
.job-card__gaps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__gaps-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-pill {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__gaps-more {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__url {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card__url:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
.job-card__date {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
305
web/src/components/JobCardStack.vue
Normal file
305
web/src/components/JobCardStack.vue
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-stack" :aria-label="`${remaining} jobs remaining`">
|
||||||
|
<!-- Peek cards — depth illusion behind active card -->
|
||||||
|
<div class="card-peek card-peek-2" aria-hidden="true" />
|
||||||
|
<div class="card-peek card-peek-1" aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- Active card wrapper — receives pointer events -->
|
||||||
|
<div
|
||||||
|
ref="wrapperEl"
|
||||||
|
class="card-wrapper"
|
||||||
|
:class="{
|
||||||
|
'is-held': isHeld,
|
||||||
|
'is-exiting': isExiting,
|
||||||
|
}"
|
||||||
|
:style="cardStyle"
|
||||||
|
role="region"
|
||||||
|
:aria-label="job.title"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerCancel"
|
||||||
|
>
|
||||||
|
<!-- Directional tint overlay -->
|
||||||
|
<div
|
||||||
|
class="card-tint"
|
||||||
|
:class="{
|
||||||
|
'card-tint--approve': dx > 0,
|
||||||
|
'card-tint--reject': dx < 0,
|
||||||
|
}"
|
||||||
|
:style="{ opacity: tintOpacity }"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span class="card-tint__icon">{{ dx > 0 ? '✓' : '✗' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JobCard
|
||||||
|
:job="job"
|
||||||
|
:expanded="isExpanded"
|
||||||
|
@expand="isExpanded = true"
|
||||||
|
@collapse="isExpanded = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import JobCard from './JobCard.vue'
|
||||||
|
import type { Job } from '../stores/review'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
job: Job
|
||||||
|
remaining: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
approve: []
|
||||||
|
reject: []
|
||||||
|
skip: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const wrapperEl = ref<HTMLElement | null>(null)
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const isHeld = ref(false)
|
||||||
|
const isExiting = ref(false)
|
||||||
|
|
||||||
|
const dx = ref(0)
|
||||||
|
const dy = ref(0)
|
||||||
|
|
||||||
|
// ─── Derived style ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Max tilt at ±120px drag = ±6°
|
||||||
|
const TILT_MAX_DEG = 6
|
||||||
|
const TILT_AT_PX = 120
|
||||||
|
|
||||||
|
const cardStyle = computed(() => {
|
||||||
|
if (isExiting.value) return {} // exiting uses CSS class transition
|
||||||
|
if (!isHeld.value && dx.value === 0 && dy.value === 0) return {}
|
||||||
|
const tilt = Math.max(-TILT_MAX_DEG, Math.min(TILT_MAX_DEG, (dx.value / TILT_AT_PX) * TILT_MAX_DEG))
|
||||||
|
return { transform: `translate(${dx.value}px, ${dy.value}px) rotate(${tilt}deg)` }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tint opacity 0→0.6 at ±0→120px
|
||||||
|
const tintOpacity = computed(() =>
|
||||||
|
isHeld.value ? Math.min(Math.abs(dx.value) / TILT_AT_PX, 1) * 0.6 : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Fling detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FLING_SPEED_PX_S = 600 // minimum px/s to qualify
|
||||||
|
const FLING_ALIGN = 0.707 // cos(45°) — must be within 45° of horizontal
|
||||||
|
const FLING_WINDOW_MS = 50 // rolling sample window
|
||||||
|
|
||||||
|
let velocityBuf: { x: number; y: number; t: number }[] = []
|
||||||
|
|
||||||
|
// ─── Zone detection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ZONE_PCT = 0.2 // 20% of viewport width on each side
|
||||||
|
|
||||||
|
// ─── Pointer events ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let pickupX = 0
|
||||||
|
let pickupY = 0
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
// Let interactive children (links, buttons) receive their events
|
||||||
|
if ((e.target as Element).closest('button, a, input, select, textarea')) return
|
||||||
|
if (isExiting.value) return
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
pickupX = e.clientX
|
||||||
|
pickupY = e.clientY
|
||||||
|
isHeld.value = true
|
||||||
|
velocityBuf = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!isHeld.value) return
|
||||||
|
dx.value = e.clientX - pickupX
|
||||||
|
dy.value = e.clientY - pickupY
|
||||||
|
|
||||||
|
// Rolling velocity buffer
|
||||||
|
const now = performance.now()
|
||||||
|
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
|
||||||
|
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
|
||||||
|
velocityBuf.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (!isHeld.value) return
|
||||||
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
|
isHeld.value = false
|
||||||
|
|
||||||
|
// Fling detection — fires first so a fast flick resolves without reaching the edge zone
|
||||||
|
if (velocityBuf.length >= 2) {
|
||||||
|
const oldest = velocityBuf[0]
|
||||||
|
const newest = velocityBuf[velocityBuf.length - 1]
|
||||||
|
const dt = (newest.t - oldest.t) / 1000
|
||||||
|
if (dt > 0) {
|
||||||
|
const vx = (newest.x - oldest.x) / dt
|
||||||
|
const vy = (newest.y - oldest.y) / dt
|
||||||
|
const speed = Math.sqrt(vx * vx + vy * vy)
|
||||||
|
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
|
||||||
|
velocityBuf = []
|
||||||
|
_dismiss(vx > 0 ? 'right' : 'left')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
velocityBuf = []
|
||||||
|
|
||||||
|
// Zone check — did the pointer release in an edge zone?
|
||||||
|
const vw = window.innerWidth
|
||||||
|
if (e.clientX < vw * ZONE_PCT) {
|
||||||
|
_dismiss('left')
|
||||||
|
} else if (e.clientX > vw * (1 - ZONE_PCT)) {
|
||||||
|
_dismiss('right')
|
||||||
|
} else {
|
||||||
|
_snapBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(e: PointerEvent) {
|
||||||
|
if (!isHeld.value) return
|
||||||
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
|
isHeld.value = false
|
||||||
|
velocityBuf = []
|
||||||
|
_snapBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animation helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _snapBack() {
|
||||||
|
dx.value = 0
|
||||||
|
dy.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fly card off-screen, then emit the action. */
|
||||||
|
async function _dismiss(direction: 'left' | 'right') {
|
||||||
|
if (!wrapperEl.value || isExiting.value) return
|
||||||
|
isExiting.value = true
|
||||||
|
|
||||||
|
const exitX = direction === 'right' ? 700 : -700
|
||||||
|
const exitTilt = direction === 'right' ? 14 : -14
|
||||||
|
wrapperEl.value.style.transform = `translate(${exitX}px, -60px) rotate(${exitTilt}deg)`
|
||||||
|
wrapperEl.value.style.opacity = '0'
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 280))
|
||||||
|
emit(direction === 'right' ? 'approve' : 'reject')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard-triggered dismiss (called from parent via template ref)
|
||||||
|
async function dismissApprove() { await _dismiss('right') }
|
||||||
|
async function dismissReject() { await _dismiss('left') }
|
||||||
|
function dismissSkip() { _snapBack(); emit('skip') }
|
||||||
|
|
||||||
|
// Reset when a new job is slotted in (Vue reuses the element)
|
||||||
|
watch(() => props.job.id, () => {
|
||||||
|
dx.value = 0
|
||||||
|
dy.value = 0
|
||||||
|
isExiting.value = false
|
||||||
|
isHeld.value = false
|
||||||
|
isExpanded.value = false
|
||||||
|
if (wrapperEl.value) {
|
||||||
|
// Suppress the spring transition for this frame — without this the card
|
||||||
|
// spring-animates from its exit position back to center before the new
|
||||||
|
// job renders (the "snap-back on processed cards" glitch).
|
||||||
|
wrapperEl.value.style.transition = 'none'
|
||||||
|
wrapperEl.value.style.transform = ''
|
||||||
|
wrapperEl.value.style.opacity = ''
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (wrapperEl.value) wrapperEl.value.style.transition = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ dismissApprove, dismissReject, dismissSkip })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-stack {
|
||||||
|
position: relative;
|
||||||
|
/* Reserve space for peek cards below active card */
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peek cards — static shadows giving a stack depth feel */
|
||||||
|
.card-peek {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
border-radius: var(--radius-card, 1rem);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-peek-1 { transform: translateY(8px) scale(0.97); opacity: 0.55; height: 80px; }
|
||||||
|
.card-peek-2 { transform: translateY(16px) scale(0.94); opacity: 0.30; height: 80px; }
|
||||||
|
|
||||||
|
/* Active card wrapper */
|
||||||
|
.card-wrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: var(--radius-card, 1rem);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
/* Spring snap-back when released with no action */
|
||||||
|
transition:
|
||||||
|
transform var(--swipe-spring),
|
||||||
|
opacity 200ms ease,
|
||||||
|
box-shadow 150ms ease;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrapper.is-held {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none; /* instant response while dragging */
|
||||||
|
box-shadow: var(--shadow-xl, 0 12px 40px rgba(0,0,0,0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* is-exiting: override to linear ease-in for off-screen fly */
|
||||||
|
.card-wrapper.is-exiting {
|
||||||
|
transition:
|
||||||
|
transform 280ms ease-in,
|
||||||
|
opacity 240ms ease-in !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directional tint overlay */
|
||||||
|
.card-tint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--space-4);
|
||||||
|
transition: opacity 60ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tint--approve { background: rgba(39, 174, 96, 0.35); }
|
||||||
|
.card-tint--reject { background: rgba(192, 57, 43, 0.35); }
|
||||||
|
|
||||||
|
.card-tint__icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tint--approve .card-tint__icon { margin-left: auto; }
|
||||||
|
.card-tint--reject .card-tint__icon { margin-right: auto; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card-wrapper { transition: none; }
|
||||||
|
.card-wrapper.is-exiting { transition: opacity 200ms ease !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
web/src/components/MoveToSheet.vue
Normal file
176
web/src/components/MoveToSheet.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { STAGE_LABELS, PIPELINE_STAGES } from '../stores/interviews'
|
||||||
|
import type { PipelineStage } from '../stores/interviews'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentStatus: string
|
||||||
|
jobTitle: string
|
||||||
|
preSelectedStage?: PipelineStage
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
move: [stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedStage = ref<PipelineStage | null>(props.preSelectedStage ?? null)
|
||||||
|
const interviewDate = ref('')
|
||||||
|
const rejectionStage = ref('')
|
||||||
|
const focusIndex = ref(0)
|
||||||
|
const firstOptionEl = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const stages = computed(() =>
|
||||||
|
PIPELINE_STAGES.filter(s => s !== props.currentStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(stage: PipelineStage) {
|
||||||
|
selectedStage.value = stage
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!selectedStage.value) return
|
||||||
|
if (!stages.value.includes(selectedStage.value)) return // guard: preSelectedStage was filtered out
|
||||||
|
const opts: { interview_date?: string; rejection_stage?: string } = {}
|
||||||
|
if (interviewDate.value) opts.interview_date = new Date(interviewDate.value).toISOString()
|
||||||
|
if (rejectionStage.value) opts.rejection_stage = rejectionStage.value
|
||||||
|
emit('move', selectedStage.value, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { emit('close'); return }
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
focusIndex.value = Math.min(focusIndex.value + 1, stages.value.length - 1)
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
focusIndex.value = Math.max(focusIndex.value - 1, 0)
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && stages.value[focusIndex.value]) {
|
||||||
|
select(stages.value[focusIndex.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
nextTick(() => firstOptionEl.value?.focus())
|
||||||
|
})
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="sheet-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="`Move ${jobTitle}`"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="sheet-panel">
|
||||||
|
<div class="sheet-header">
|
||||||
|
<span class="sheet-title">Move to…</span>
|
||||||
|
<button class="sheet-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-stages" role="listbox">
|
||||||
|
<button
|
||||||
|
v-for="(stage, i) in stages"
|
||||||
|
:key="stage"
|
||||||
|
:ref="i === 0 ? (el) => { firstOptionEl = el as HTMLButtonElement } : undefined"
|
||||||
|
class="stage-option"
|
||||||
|
:class="{
|
||||||
|
'stage-option--selected': selectedStage === stage,
|
||||||
|
'stage-option--focused': focusIndex === i,
|
||||||
|
}"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="selectedStage === stage"
|
||||||
|
@click="select(stage)"
|
||||||
|
>
|
||||||
|
{{ STAGE_LABELS[stage] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedStage === 'phone_screen' || selectedStage === 'interviewing'"
|
||||||
|
class="sheet-extras"
|
||||||
|
>
|
||||||
|
<label class="field-label">
|
||||||
|
Interview date/time (optional)
|
||||||
|
<input type="datetime-local" v-model="interviewDate" class="field-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedStage === 'interview_rejected'" class="sheet-extras">
|
||||||
|
<label class="field-label">
|
||||||
|
Rejected after…
|
||||||
|
<select v-model="rejectionStage" class="field-input">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
<option>Application</option>
|
||||||
|
<option>Phone screen</option>
|
||||||
|
<option>Interviewing</option>
|
||||||
|
<option>Offer stage</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<button class="btn-cancel" @click="emit('close')">Cancel</button>
|
||||||
|
<button class="btn-confirm" :disabled="!selectedStage" @click="confirm">Move →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sheet-backdrop { align-items: center; }
|
||||||
|
}
|
||||||
|
.sheet-panel {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-6);
|
||||||
|
width: 100%; max-width: 480px;
|
||||||
|
display: flex; flex-direction: column; gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sheet-panel { border-radius: 12px; }
|
||||||
|
}
|
||||||
|
.sheet-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sheet-title { font-weight: 700; font-size: 1rem; }
|
||||||
|
.sheet-close { background: none; border: none; cursor: pointer; font-size: 1rem; color: var(--color-text-muted); }
|
||||||
|
.sheet-stages { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.stage-option {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px; padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: 0.9rem; font-weight: 600; text-align: left;
|
||||||
|
cursor: pointer; color: var(--color-text);
|
||||||
|
transition: border-color 120ms, background 120ms;
|
||||||
|
}
|
||||||
|
.stage-option:hover { background: var(--color-surface-alt); }
|
||||||
|
.stage-option--selected { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||||
|
.stage-option--focused { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.sheet-extras { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.field-label { font-size: 0.8rem; font-weight: 600; color: var(--color-text-muted); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.field-input { padding: var(--space-2); border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); font-size: 0.875rem; color: var(--color-text); }
|
||||||
|
.sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
|
||||||
|
.btn-cancel {
|
||||||
|
background: var(--color-surface-alt); border: none; border-radius: 8px;
|
||||||
|
padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--color-primary); border: none; border-radius: 8px;
|
||||||
|
padding: var(--space-2) var(--space-4); font-weight: 700; cursor: pointer;
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
.btn-confirm:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
96
web/src/components/WorkflowButton.vue
Normal file
96
web/src/components/WorkflowButton.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="workflow-btn"
|
||||||
|
:class="{ 'workflow-btn--loading': loading }"
|
||||||
|
:disabled="loading"
|
||||||
|
:aria-busy="loading"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<span class="workflow-btn__icon" aria-hidden="true">{{ emoji }}</span>
|
||||||
|
<span class="workflow-btn__body">
|
||||||
|
<span class="workflow-btn__label">{{ label }}</span>
|
||||||
|
<span class="workflow-btn__desc">{{ description }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="loading" class="workflow-btn__spinner" aria-label="Running…" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
emoji: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 72px; /* WCAG 2.5.5 */
|
||||||
|
width: 100%;
|
||||||
|
/* Enumerate transitions — no transition:all. Gotcha #2. */
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn:hover {
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn__icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-btn__spinner {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
50
web/src/composables/useApi.ts
Normal file
50
web/src/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
export type ApiError =
|
||||||
|
| { kind: 'network'; message: string }
|
||||||
|
| { kind: 'http'; status: number; detail: string }
|
||||||
|
|
||||||
|
export async function useApiFetch<T>(
|
||||||
|
url: string,
|
||||||
|
opts?: RequestInit,
|
||||||
|
): Promise<{ data: T | null; error: ApiError | null }> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => '')
|
||||||
|
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
||||||
|
}
|
||||||
|
const data = await res.json() as T
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { data: null, error: { kind: 'network', message: String(e) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an SSE connection. Returns a cleanup function.
|
||||||
|
* onEvent receives each parsed JSON payload.
|
||||||
|
* onComplete is called when the server sends a {"type":"complete"} event.
|
||||||
|
* onError is called on connection error.
|
||||||
|
*/
|
||||||
|
export function useApiSSE(
|
||||||
|
url: string,
|
||||||
|
onEvent: (data: Record<string, unknown>) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
onError?: (e: Event) => void,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource(url)
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data) as Record<string, unknown>
|
||||||
|
onEvent(data)
|
||||||
|
if (data.type === 'complete') {
|
||||||
|
es.close()
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed events */ }
|
||||||
|
}
|
||||||
|
es.onerror = (e) => {
|
||||||
|
onError?.(e)
|
||||||
|
es.close()
|
||||||
|
}
|
||||||
|
return () => es.close()
|
||||||
|
}
|
||||||
160
web/src/composables/useEasterEgg.ts
Normal file
160
web/src/composables/useEasterEgg.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
|
||||||
|
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
|
||||||
|
|
||||||
|
export function useKeySequence(sequence: string[], onActivate: () => void) {
|
||||||
|
let pos = 0
|
||||||
|
|
||||||
|
function handler(e: KeyboardEvent) {
|
||||||
|
if (e.key === sequence[pos]) {
|
||||||
|
pos++
|
||||||
|
if (pos === sequence.length) {
|
||||||
|
pos = 0
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handler))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKonamiCode(onActivate: () => void) {
|
||||||
|
useKeySequence(KONAMI, onActivate)
|
||||||
|
useKeySequence(KONAMI_AB, onActivate)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHackerMode() {
|
||||||
|
function toggle() {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (root.dataset.theme === 'hacker') {
|
||||||
|
delete root.dataset.theme
|
||||||
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
|
} else {
|
||||||
|
root.dataset.theme = 'hacker'
|
||||||
|
localStorage.setItem('cf-hacker-mode', 'true')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
if (localStorage.getItem('cf-hacker-mode') === 'true') {
|
||||||
|
document.documentElement.dataset.theme = 'hacker'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toggle, restore }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
|
||||||
|
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
|
||||||
|
if (typeof requestAnimationFrame === 'undefined') return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
document.body.appendChild(canvas)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
const COLORS = ['#2d5a27','#c4732a','#5A9DBF','#D4854A','#FFC107','#4CAF50']
|
||||||
|
const particles = Array.from({ length: 80 }, () => ({
|
||||||
|
x: originX,
|
||||||
|
y: originY,
|
||||||
|
vx: (Math.random() - 0.5) * 14,
|
||||||
|
vy: (Math.random() - 0.6) * 12,
|
||||||
|
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||||
|
size: 5 + Math.random() * 6,
|
||||||
|
angle: Math.random() * Math.PI * 2,
|
||||||
|
spin: (Math.random() - 0.5) * 0.3,
|
||||||
|
life: 1.0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
let alive = false
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.vy += 0.35
|
||||||
|
p.vx *= 0.98
|
||||||
|
p.angle += p.spin
|
||||||
|
p.life -= 0.016
|
||||||
|
if (p.life <= 0) continue
|
||||||
|
alive = true
|
||||||
|
ctx.save()
|
||||||
|
ctx.globalAlpha = p.life
|
||||||
|
ctx.fillStyle = p.color
|
||||||
|
ctx.translate(p.x, p.y)
|
||||||
|
ctx.rotate(p.angle)
|
||||||
|
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
if (alive) {
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
canvas.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable cursor trail in hacker mode — returns a cleanup function. */
|
||||||
|
export function useCursorTrail() {
|
||||||
|
const DOT_COUNT = 10
|
||||||
|
const dots: HTMLElement[] = []
|
||||||
|
let positions: { x: number; y: number }[] = []
|
||||||
|
let mouseX = 0
|
||||||
|
let mouseY = 0
|
||||||
|
let raf = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < DOT_COUNT; i++) {
|
||||||
|
const dot = document.createElement('div')
|
||||||
|
dot.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'pointer-events:none',
|
||||||
|
'z-index:9998',
|
||||||
|
'border-radius:50%',
|
||||||
|
'background:var(--color-accent)',
|
||||||
|
'transition:opacity 0.1s',
|
||||||
|
].join(';')
|
||||||
|
document.body.appendChild(dot)
|
||||||
|
dots.push(dot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
mouseX = e.clientX
|
||||||
|
mouseY = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
positions.unshift({ x: mouseX, y: mouseY })
|
||||||
|
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
|
||||||
|
|
||||||
|
dots.forEach((dot, i) => {
|
||||||
|
const pos = positions[i]
|
||||||
|
if (!pos) { dot.style.opacity = '0'; return }
|
||||||
|
const scale = 1 - i / DOT_COUNT
|
||||||
|
const size = Math.round(8 * scale)
|
||||||
|
dot.style.left = `${pos.x - size / 2}px`
|
||||||
|
dot.style.top = `${pos.y - size / 2}px`
|
||||||
|
dot.style.width = `${size}px`
|
||||||
|
dot.style.height = `${size}px`
|
||||||
|
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
|
||||||
|
})
|
||||||
|
raf = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
raf = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
dots.forEach(d => d.remove())
|
||||||
|
}
|
||||||
|
}
|
||||||
48
web/src/composables/useFeatureFlag.ts
Normal file
48
web/src/composables/useFeatureFlag.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* useFeatureFlag — demo toolbar tier display helper.
|
||||||
|
*
|
||||||
|
* Reads the `prgn_demo_tier` cookie set by the Streamlit demo toolbar so the
|
||||||
|
* Vue SPA can visually reflect the simulated tier (e.g. in ClassicUIButton
|
||||||
|
* or feature-locked UI hints).
|
||||||
|
*
|
||||||
|
* ⚠️ NOT an authoritative feature gate. This is demo-only visual consistency.
|
||||||
|
* Production feature gating will use a future /api/features endpoint (issue #8).
|
||||||
|
* All real access control lives in the Python tier system (app/wizard/tiers.py).
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const VALID_TIERS = ['free', 'paid', 'premium'] as const
|
||||||
|
type Tier = (typeof VALID_TIERS)[number]
|
||||||
|
|
||||||
|
function _readDemoTierCookie(): Tier | null {
|
||||||
|
const match = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('prgn_demo_tier='))
|
||||||
|
if (!match) return null
|
||||||
|
const value = match.split('=')[1] as Tier
|
||||||
|
return VALID_TIERS.includes(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the simulated demo tier from the `prgn_demo_tier` cookie,
|
||||||
|
* or `null` when not in demo mode (cookie absent).
|
||||||
|
*
|
||||||
|
* Use for visual indicators only — never for access control.
|
||||||
|
*/
|
||||||
|
export function useFeatureFlag() {
|
||||||
|
const demoTier = computed<Tier | null>(() => _readDemoTierCookie())
|
||||||
|
|
||||||
|
const isDemoMode = computed(() => demoTier.value !== null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the simulated demo tier meets `required`.
|
||||||
|
* Always returns false outside demo mode.
|
||||||
|
*/
|
||||||
|
function demoCanUse(required: Tier): boolean {
|
||||||
|
const order: Tier[] = ['free', 'paid', 'premium']
|
||||||
|
if (!demoTier.value) return false
|
||||||
|
return order.indexOf(demoTier.value) >= order.indexOf(required)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { demoTier, isDemoMode, demoCanUse }
|
||||||
|
}
|
||||||
20
web/src/composables/useHaptics.ts
Normal file
20
web/src/composables/useHaptics.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useMotion } from './useMotion'
|
||||||
|
|
||||||
|
// navigator.vibrate() — Chrome for Android only. Desktop, iOS Safari: no-op.
|
||||||
|
// Always guard with feature detection. Gotcha #9.
|
||||||
|
export function useHaptics() {
|
||||||
|
const { rich } = useMotion()
|
||||||
|
|
||||||
|
function vibrate(pattern: number | number[]) {
|
||||||
|
if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: () => vibrate(40),
|
||||||
|
discard: () => vibrate([40, 30, 40]),
|
||||||
|
skip: () => vibrate(15),
|
||||||
|
undo: () => vibrate([20, 20, 60]),
|
||||||
|
}
|
||||||
|
}
|
||||||
30
web/src/composables/useMotion.ts
Normal file
30
web/src/composables/useMotion.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
// Peregrine-namespaced localStorage entry (avocet uses cf-avocet-rich-motion)
|
||||||
|
const LS_MOTION = 'cf-peregrine-rich-motion'
|
||||||
|
|
||||||
|
// OS-level prefers-reduced-motion — checked once at module load
|
||||||
|
const OS_REDUCED = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Reactive ref so toggling localStorage triggers re-reads in the same session
|
||||||
|
const _richOverride = ref(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem(LS_MOTION)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
export function useMotion() {
|
||||||
|
// null/missing = default ON; 'false' = explicitly disabled by user
|
||||||
|
const rich = computed(() =>
|
||||||
|
!OS_REDUCED && _richOverride.value !== 'false',
|
||||||
|
)
|
||||||
|
|
||||||
|
function setRich(enabled: boolean) {
|
||||||
|
localStorage.setItem(LS_MOTION, enabled ? 'true' : 'false')
|
||||||
|
_richOverride.value = enabled ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rich, setRich }
|
||||||
|
}
|
||||||
24
web/src/main.ts
Normal file
24
web/src/main.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { router } from './router'
|
||||||
|
|
||||||
|
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
|
||||||
|
import '@fontsource/fraunces/400.css'
|
||||||
|
import '@fontsource/fraunces/700.css'
|
||||||
|
import '@fontsource/atkinson-hyperlegible/400.css'
|
||||||
|
import '@fontsource/atkinson-hyperlegible/700.css'
|
||||||
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
|
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
import './assets/theme.css'
|
||||||
|
import './assets/peregrine.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// Manual scroll restoration — prevents browser from jumping to last position on SPA nav
|
||||||
|
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
44
web/src/router/index.ts
Normal file
44
web/src/router/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
import { settingsGuard } from './settingsGuard'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: () => import('../views/HomeView.vue') },
|
||||||
|
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
|
||||||
|
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
|
||||||
|
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||||
|
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||||
|
{ path: '/digest', component: () => import('../views/DigestView.vue') },
|
||||||
|
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
|
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
|
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||||
|
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
component: () => import('../views/settings/SettingsView.vue'),
|
||||||
|
redirect: '/settings/my-profile',
|
||||||
|
children: [
|
||||||
|
{ path: 'my-profile', component: () => import('../views/settings/MyProfileView.vue') },
|
||||||
|
{ path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
|
||||||
|
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
|
||||||
|
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') },
|
||||||
|
{ path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
|
||||||
|
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') },
|
||||||
|
{ path: 'data', component: () => import('../views/settings/DataView.vue') },
|
||||||
|
{ path: 'privacy', component: () => import('../views/settings/PrivacyView.vue') },
|
||||||
|
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, _from, next) => {
|
||||||
|
if (!to.path.startsWith('/settings/')) return next()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
if (!config.loaded) await config.load()
|
||||||
|
settingsGuard(to, _from, next)
|
||||||
|
})
|
||||||
135
web/src/router/settings.guard.test.ts
Normal file
135
web/src/router/settings.guard.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
import { settingsGuard } from './settingsGuard'
|
||||||
|
|
||||||
|
vi.mock('../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
|
||||||
|
describe('settingsGuard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through non-settings routes immediately', () => {
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/review' }, {}, next)
|
||||||
|
// Guard only handles /settings/* — for non-settings routes the router
|
||||||
|
// calls next() before reaching settingsGuard, but the guard itself
|
||||||
|
// will still call next() with no redirect since no tab matches
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects /settings/system in cloud mode', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/system' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith('/settings/my-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/system in self-hosted mode', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/system' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects /settings/fine-tune for non-GPU self-hosted', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
store.inferenceProfile = 'cpu'
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith('/settings/my-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/fine-tune for single-gpu self-hosted', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
store.inferenceProfile = 'single-gpu'
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/fine-tune for dual-gpu self-hosted', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
store.inferenceProfile = 'dual-gpu'
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects /settings/fine-tune on cloud when tier is not premium', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
store.tier = 'paid'
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith('/settings/my-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/fine-tune on cloud when tier is premium', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
store.tier = 'premium'
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects /settings/developer when not dev mode and no override', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isDevMode = false
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/developer' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith('/settings/my-profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/developer when isDevMode is true', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isDevMode = true
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/developer' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/developer when dev_tier_override set in localStorage', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isDevMode = false
|
||||||
|
localStorage.setItem('dev_tier_override', 'premium')
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/developer' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/privacy in cloud mode', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/privacy' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/privacy in self-hosted mode', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/privacy' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /settings/license in both modes', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
const next = vi.fn()
|
||||||
|
settingsGuard({ path: '/settings/license' }, {}, next)
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
web/src/router/settingsGuard.ts
Normal file
31
web/src/router/settingsGuard.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const GPU_PROFILES = ['single-gpu', 'dual-gpu']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous tab-gating logic for /settings/* routes.
|
||||||
|
* Called by the async router.beforeEach after config.load() has resolved.
|
||||||
|
* Reading devTierOverride from localStorage here (not only the store ref) ensures
|
||||||
|
* the guard reflects overrides set externally before the store hydrates.
|
||||||
|
*/
|
||||||
|
export function settingsGuard(
|
||||||
|
to: { path: string },
|
||||||
|
_from: unknown,
|
||||||
|
next: (to?: string) => void,
|
||||||
|
): void {
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const tab = to.path.replace('/settings/', '')
|
||||||
|
const devOverride = config.devTierOverride || localStorage.getItem('dev_tier_override')
|
||||||
|
|
||||||
|
if (tab === 'system' && config.isCloud) return next('/settings/my-profile')
|
||||||
|
|
||||||
|
if (tab === 'fine-tune') {
|
||||||
|
const cloudBlocked = config.isCloud && config.tier !== 'premium'
|
||||||
|
const selfHostedBlocked = !config.isCloud && !GPU_PROFILES.includes(config.inferenceProfile)
|
||||||
|
if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile')
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
41
web/src/stores/appConfig.test.ts
Normal file
41
web/src/stores/appConfig.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAppConfigStore } from './appConfig'
|
||||||
|
|
||||||
|
vi.mock('../composables/useApi', () => ({
|
||||||
|
useApiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useAppConfigStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to safe values before load', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
expect(store.isCloud).toBe(false)
|
||||||
|
expect(store.tier).toBe('free')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() populates from API response', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { isCloud: true, isDevMode: false, tier: 'paid', contractedClient: false, inferenceProfile: 'cpu' },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.isCloud).toBe(true)
|
||||||
|
expect(store.tier).toBe('paid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() error leaves defaults intact', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.isCloud).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
42
web/src/stores/appConfig.ts
Normal file
42
web/src/stores/appConfig.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export type Tier = 'free' | 'paid' | 'premium' | 'ultra'
|
||||||
|
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
|
||||||
|
|
||||||
|
export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
|
const isCloud = ref(false)
|
||||||
|
const isDevMode = ref(false)
|
||||||
|
const tier = ref<Tier>('free')
|
||||||
|
const contractedClient = ref(false)
|
||||||
|
const inferenceProfile = ref<InferenceProfile>('cpu')
|
||||||
|
const loaded = ref(false)
|
||||||
|
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { data } = await useApiFetch<{
|
||||||
|
isCloud: boolean; isDevMode: boolean; tier: Tier
|
||||||
|
contractedClient: boolean; inferenceProfile: InferenceProfile
|
||||||
|
}>('/api/config/app')
|
||||||
|
if (!data) return
|
||||||
|
isCloud.value = data.isCloud
|
||||||
|
isDevMode.value = data.isDevMode
|
||||||
|
tier.value = data.tier
|
||||||
|
contractedClient.value = data.contractedClient
|
||||||
|
inferenceProfile.value = data.inferenceProfile
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDevTierOverride(value: string | null) {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('dev_tier_override', value)
|
||||||
|
devTierOverride.value = value
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dev_tier_override')
|
||||||
|
devTierOverride.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
|
||||||
|
})
|
||||||
50
web/src/stores/digest.ts
Normal file
50
web/src/stores/digest.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface DigestEntry {
|
||||||
|
id: number
|
||||||
|
job_contact_id: number
|
||||||
|
created_at: string
|
||||||
|
subject: string
|
||||||
|
from_addr: string | null
|
||||||
|
received_at: string
|
||||||
|
body: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracted link from a digest email body. Used by DigestView.vue. */
|
||||||
|
export interface DigestLink {
|
||||||
|
url: string
|
||||||
|
score: number // 2 = job-likely, 1 = other
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDigestStore = defineStore('digest', () => {
|
||||||
|
const entries = ref<DigestEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
const { data, error: err } = await useApiFetch<DigestEntry[]>('/api/digest-queue')
|
||||||
|
loading.value = false
|
||||||
|
if (err) {
|
||||||
|
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
const snapshot = entries.value.find(e => e.id === id)
|
||||||
|
entries.value = entries.value.filter(e => e.id !== id)
|
||||||
|
const { error: err } = await useApiFetch(`/api/digest-queue/${id}`, { method: 'DELETE' })
|
||||||
|
if (err) {
|
||||||
|
if (snapshot) entries.value = [...entries.value, snapshot]
|
||||||
|
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, loading, error, fetchAll, remove }
|
||||||
|
})
|
||||||
50
web/src/stores/interviews.test.ts
Normal file
50
web/src/stores/interviews.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useInterviewsStore } from './interviews'
|
||||||
|
|
||||||
|
vi.mock('../composables/useApi', () => ({
|
||||||
|
useApiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
const SAMPLE_JOBS = [
|
||||||
|
{ id: 1, title: 'CS Lead', company: 'Stripe', status: 'applied', match_score: 0.92, interview_date: null },
|
||||||
|
{ id: 2, title: 'CS Dir', company: 'Notion', status: 'phone_screen', match_score: 0.78, interview_date: '2026-03-20T15:00:00' },
|
||||||
|
{ id: 3, title: 'VP CS', company: 'Linear', status: 'hired', match_score: 0.95, interview_date: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('useInterviewsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
mockFetch.mockResolvedValue({ data: SAMPLE_JOBS, error: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads and groups jobs by status', async () => {
|
||||||
|
const store = useInterviewsStore()
|
||||||
|
await store.fetchAll()
|
||||||
|
expect(store.applied).toHaveLength(1)
|
||||||
|
expect(store.phoneScreen).toHaveLength(1)
|
||||||
|
expect(store.hired).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('move updates status optimistically', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
|
||||||
|
mockFetch.mockResolvedValueOnce({ data: null, error: null }) // move API
|
||||||
|
const store = useInterviewsStore()
|
||||||
|
await store.fetchAll()
|
||||||
|
await store.move(1, 'phone_screen')
|
||||||
|
expect(store.applied).toHaveLength(0)
|
||||||
|
expect(store.phoneScreen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('move rolls back on API error', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
|
||||||
|
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'err' } })
|
||||||
|
const store = useInterviewsStore()
|
||||||
|
await store.fetchAll()
|
||||||
|
await store.move(1, 'phone_screen')
|
||||||
|
expect(store.applied).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
90
web/src/stores/interviews.ts
Normal file
90
web/src/stores/interviews.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface StageSignal {
|
||||||
|
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
|
||||||
|
subject: string
|
||||||
|
received_at: string // ISO timestamp
|
||||||
|
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
|
||||||
|
body: string | null // email body text; null if not available
|
||||||
|
from_addr: string | null // sender address; null if not available
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineJob {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
url: string | null
|
||||||
|
location: string | null
|
||||||
|
is_remote: boolean
|
||||||
|
salary: string | null
|
||||||
|
match_score: number | null
|
||||||
|
keyword_gaps: string | null
|
||||||
|
status: string
|
||||||
|
interview_date: string | null
|
||||||
|
rejection_stage: string | null
|
||||||
|
applied_at: string | null
|
||||||
|
phone_screen_at: string | null
|
||||||
|
interviewing_at: string | null
|
||||||
|
offer_at: string | null
|
||||||
|
hired_at: string | null
|
||||||
|
survey_at: string | null
|
||||||
|
stage_signals: StageSignal[] // undismissed signals, newest first
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PIPELINE_STAGES = ['applied', 'survey', 'phone_screen', 'interviewing', 'offer', 'hired', 'interview_rejected'] as const
|
||||||
|
export type PipelineStage = typeof PIPELINE_STAGES[number]
|
||||||
|
|
||||||
|
export const STAGE_LABELS: Record<PipelineStage, string> = {
|
||||||
|
applied: 'Applied',
|
||||||
|
survey: 'Survey',
|
||||||
|
phone_screen: 'Phone Screen',
|
||||||
|
interviewing: 'Interviewing',
|
||||||
|
offer: 'Offer',
|
||||||
|
hired: 'Hired',
|
||||||
|
interview_rejected: 'Rejected',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInterviewsStore = defineStore('interviews', () => {
|
||||||
|
const jobs = ref<PipelineJob[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const applied = computed(() => jobs.value.filter(j => j.status === 'applied'))
|
||||||
|
const survey = computed(() => jobs.value.filter(j => j.status === 'survey'))
|
||||||
|
const phoneScreen = computed(() => jobs.value.filter(j => j.status === 'phone_screen'))
|
||||||
|
const interviewing = computed(() => jobs.value.filter(j => j.status === 'interviewing'))
|
||||||
|
const offer = computed(() => jobs.value.filter(j => j.status === 'offer'))
|
||||||
|
const hired = computed(() => jobs.value.filter(j => j.status === 'hired'))
|
||||||
|
const offerHired = computed(() => jobs.value.filter(j => j.status === 'offer' || j.status === 'hired'))
|
||||||
|
const rejected = computed(() => jobs.value.filter(j => j.status === 'interview_rejected'))
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
loading.value = true
|
||||||
|
const { data, error: err } = await useApiFetch<PipelineJob[]>('/api/interviews')
|
||||||
|
loading.value = false
|
||||||
|
if (err) { error.value = 'Could not load interview pipeline'; return }
|
||||||
|
jobs.value = (data ?? []).map(j => ({ ...j }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(jobId: number, status: PipelineStage, opts: { interview_date?: string; rejection_stage?: string } = {}) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
const prevStatus = job.status
|
||||||
|
job.status = status
|
||||||
|
|
||||||
|
const { error: err } = await useApiFetch(`/api/jobs/${jobId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status, ...opts }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
job.status = prevStatus
|
||||||
|
error.value = 'Move failed — please try again'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { jobs, loading, error, applied, survey, phoneScreen, interviewing, offer, hired, offerHired, rejected, fetchAll, move }
|
||||||
|
})
|
||||||
50
web/src/stores/jobs.ts
Normal file
50
web/src/stores/jobs.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface JobCounts {
|
||||||
|
pending: number
|
||||||
|
approved: number
|
||||||
|
applied: number
|
||||||
|
synced: number
|
||||||
|
rejected: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
enrichment_enabled: boolean
|
||||||
|
enrichment_last_run: string | null
|
||||||
|
enrichment_next_run: string | null
|
||||||
|
tasks_running: number
|
||||||
|
integration_name: string | null // e.g. "Notion", "Airtable"
|
||||||
|
integration_unsynced: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinia setup store — function form, not options form (gotcha #10)
|
||||||
|
export const useJobsStore = defineStore('jobs', () => {
|
||||||
|
const counts = ref<JobCounts | null>(null)
|
||||||
|
const status = ref<SystemStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const hasPending = computed(() => (counts.value?.pending ?? 0) > 0)
|
||||||
|
|
||||||
|
async function fetchCounts() {
|
||||||
|
loading.value = true
|
||||||
|
const { data, error: err } = await useApiFetch<JobCounts>('/api/jobs/counts')
|
||||||
|
loading.value = false
|
||||||
|
if (err) { error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`; return }
|
||||||
|
counts.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
const { data } = await useApiFetch<SystemStatus>('/api/system/status')
|
||||||
|
if (data) status.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await Promise.all([fetchCounts(), fetchStatus()])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { counts, status, loading, error, hasPending, fetchCounts, fetchStatus, refresh }
|
||||||
|
})
|
||||||
186
web/src/stores/prep.test.ts
Normal file
186
web/src/stores/prep.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { usePrepStore } from './prep'
|
||||||
|
|
||||||
|
// Mock useApiFetch
|
||||||
|
vi.mock('../composables/useApi', () => ({
|
||||||
|
useApiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
describe('usePrepStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor loads research, contacts, task, and full job in parallel', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
keyword_gaps: null }, error: null }) // fullJob
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
expect(store.research?.company_brief).toBe('Acme')
|
||||||
|
expect(store.contacts).toEqual([])
|
||||||
|
expect(store.taskStatus.status).toBe('none')
|
||||||
|
expect(store.fullJob?.description).toBe('Build things.')
|
||||||
|
expect(store.currentJobId).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor clears state when called for a different job', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// First call for job 1
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'OldCo', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
expect(store.research?.company_brief).toBe('OldCo')
|
||||||
|
|
||||||
|
// Second call for job 2 - clears first
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
||||||
|
await store.fetchFor(2)
|
||||||
|
expect(store.research).toBeNull()
|
||||||
|
expect(store.currentJobId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateResearch calls POST then starts polling', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
store.currentJobId = 1
|
||||||
|
|
||||||
|
// Spy on pollTask via the interval
|
||||||
|
const pollSpy = mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'running', stage: 'Analyzing', message: null }, error: null })
|
||||||
|
|
||||||
|
await store.generateResearch(1)
|
||||||
|
|
||||||
|
// Advance timer one tick — should poll
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
|
||||||
|
// Should have called POST generate + poll task
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/research/generate'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pollTask stops when status is completed and re-fetches research', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// Set up store with a job loaded
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
// Mock first poll → completed
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
|
// re-fetch on completed: research, contacts, task, fullJob
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: '2026-03-20T13:00:00' }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
||||||
|
store.pollTask(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
|
||||||
|
expect(store.research?.company_brief).toBe('Updated!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear cancels polling interval and resets state', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
store.pollTask(1)
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
// Advance timers — if polling wasn't cancelled, fetchFor would be called again
|
||||||
|
const callCountBeforeClear = mockApiFetch.mock.calls.length
|
||||||
|
await vi.advanceTimersByTimeAsync(9000)
|
||||||
|
expect(mockApiFetch.mock.calls.length).toBe(callCountBeforeClear)
|
||||||
|
|
||||||
|
expect(store.research).toBeNull()
|
||||||
|
expect(store.contacts).toEqual([])
|
||||||
|
expect(store.contactsError).toBeNull()
|
||||||
|
expect(store.currentJobId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor sets contactsError and leaves other data intact when contacts fetch fails', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
|
||||||
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
||||||
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
keyword_gaps: null }, error: null }) // fullJob OK
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
// Contacts error shown in Email tab only
|
||||||
|
expect(store.contactsError).toBe('Could not load email history.')
|
||||||
|
expect(store.contacts).toEqual([])
|
||||||
|
|
||||||
|
// Everything else still renders
|
||||||
|
expect(store.research?.company_brief).toBe('Acme')
|
||||||
|
expect(store.fullJob?.description).toBe('Build things.')
|
||||||
|
expect(store.fullJob?.match_score).toBe(80)
|
||||||
|
expect(store.taskStatus.status).toBe('none')
|
||||||
|
|
||||||
|
// Top-level error stays null (no full-panel blank-out)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
173
web/src/stores/prep.ts
Normal file
173
web/src/stores/prep.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface ResearchBrief {
|
||||||
|
company_brief: string | null
|
||||||
|
ceo_brief: string | null
|
||||||
|
talking_points: string | null
|
||||||
|
tech_brief: string | null
|
||||||
|
funding_brief: string | null
|
||||||
|
red_flags: string | null
|
||||||
|
accessibility_brief: string | null
|
||||||
|
generated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: number
|
||||||
|
direction: 'inbound' | 'outbound'
|
||||||
|
subject: string | null
|
||||||
|
from_addr: string | null
|
||||||
|
body: string | null
|
||||||
|
received_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatus {
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
|
||||||
|
stage: string | null
|
||||||
|
message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullJobDetail {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
url: string | null
|
||||||
|
description: string | null
|
||||||
|
cover_letter: string | null
|
||||||
|
match_score: number | null
|
||||||
|
keyword_gaps: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrepStore = defineStore('prep', () => {
|
||||||
|
const research = ref<ResearchBrief | null>(null)
|
||||||
|
const contacts = ref<Contact[]>([])
|
||||||
|
const contactsError = ref<string | null>(null)
|
||||||
|
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
|
||||||
|
const fullJob = ref<FullJobDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const currentJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function _clearInterval() {
|
||||||
|
if (pollInterval !== null) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFor(jobId: number) {
|
||||||
|
if (jobId !== currentJobId.value) {
|
||||||
|
_clearInterval()
|
||||||
|
research.value = null
|
||||||
|
contacts.value = []
|
||||||
|
contactsError.value = null
|
||||||
|
taskStatus.value = { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = null
|
||||||
|
error.value = null
|
||||||
|
currentJobId.value = jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [researchResult, contactsResult, taskResult, jobResult] = await Promise.all([
|
||||||
|
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
|
||||||
|
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
|
||||||
|
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
|
||||||
|
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Research 404 is expected (no research yet) — only surface non-404 errors
|
||||||
|
if (researchResult.error && !(researchResult.error.kind === 'http' && researchResult.error.status === 404)) {
|
||||||
|
error.value = 'Failed to load research data'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (jobResult.error) {
|
||||||
|
error.value = 'Failed to load job details'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
research.value = researchResult.data ?? null
|
||||||
|
|
||||||
|
// Contacts failure is non-fatal — degrade the Email tab only
|
||||||
|
if (contactsResult.error) {
|
||||||
|
contactsError.value = 'Could not load email history.'
|
||||||
|
contacts.value = []
|
||||||
|
} else {
|
||||||
|
contacts.value = contactsResult.data ?? []
|
||||||
|
contactsError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = jobResult.data ?? null
|
||||||
|
|
||||||
|
// If a task is already running/queued, start polling
|
||||||
|
const ts = taskStatus.value.status
|
||||||
|
if (ts === 'queued' || ts === 'running') {
|
||||||
|
pollTask(jobId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load prep data'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateResearch(jobId: number) {
|
||||||
|
const { data, error: fetchError } = await useApiFetch<{ task_id: number; is_new: boolean }>(
|
||||||
|
`/api/jobs/${jobId}/research/generate`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
if (fetchError || !data) {
|
||||||
|
error.value = 'Failed to start research generation'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollTask(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — called by fetchFor and generateResearch; not for component use */
|
||||||
|
function pollTask(jobId: number) {
|
||||||
|
_clearInterval()
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
const { data } = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
|
||||||
|
if (data) {
|
||||||
|
taskStatus.value = data
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
|
_clearInterval()
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
await fetchFor(jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
_clearInterval()
|
||||||
|
research.value = null
|
||||||
|
contacts.value = []
|
||||||
|
contactsError.value = null
|
||||||
|
taskStatus.value = { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = null
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
currentJobId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
research,
|
||||||
|
contacts,
|
||||||
|
contactsError,
|
||||||
|
taskStatus,
|
||||||
|
fullJob,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentJobId,
|
||||||
|
fetchFor,
|
||||||
|
generateResearch,
|
||||||
|
pollTask,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
144
web/src/stores/review.ts
Normal file
144
web/src/stores/review.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
url: string
|
||||||
|
source: string | null
|
||||||
|
location: string | null
|
||||||
|
is_remote: boolean
|
||||||
|
salary: string | null
|
||||||
|
description: string | null
|
||||||
|
match_score: number | null
|
||||||
|
keyword_gaps: string | null // JSON-encoded string[]
|
||||||
|
date_found: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UndoEntry {
|
||||||
|
job: Job
|
||||||
|
action: 'approve' | 'reject' | 'skip'
|
||||||
|
prevStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stoop speed: 10 cards in 60 seconds — easter egg 9.2
|
||||||
|
const STOOP_CARDS = 10
|
||||||
|
const STOOP_SECS = 60
|
||||||
|
|
||||||
|
export const useReviewStore = defineStore('review', () => {
|
||||||
|
const queue = ref<Job[]>([])
|
||||||
|
const listJobs = ref<Job[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const undoStack = ref<UndoEntry[]>([])
|
||||||
|
const sessionStart = ref<number | null>(null)
|
||||||
|
const sessionCount = ref(0)
|
||||||
|
const stoopAchieved = ref(false)
|
||||||
|
|
||||||
|
const currentJob = computed(() => queue.value[0] ?? null)
|
||||||
|
const remaining = computed(() => queue.value.length)
|
||||||
|
|
||||||
|
const isStoopSpeed = computed(() => {
|
||||||
|
if (stoopAchieved.value || !sessionStart.value) return false
|
||||||
|
const elapsed = (Date.now() - sessionStart.value) / 1000
|
||||||
|
return sessionCount.value >= STOOP_CARDS && elapsed <= STOOP_SECS
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchQueue() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const { data, error: err } = await useApiFetch<Job[]>('/api/jobs?status=pending&limit=50')
|
||||||
|
loading.value = false
|
||||||
|
if (err) { error.value = 'Failed to load queue'; return }
|
||||||
|
queue.value = data ?? []
|
||||||
|
// Start session clock on first load with items
|
||||||
|
if (!sessionStart.value && queue.value.length > 0) {
|
||||||
|
sessionStart.value = Date.now()
|
||||||
|
sessionCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchList(status: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const { data, error: err } = await useApiFetch<Job[]>(`/api/jobs?status=${encodeURIComponent(status)}`)
|
||||||
|
loading.value = false
|
||||||
|
if (err) { error.value = 'Failed to load jobs'; return }
|
||||||
|
listJobs.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(job: Job) {
|
||||||
|
const { error: err } = await useApiFetch(`/api/jobs/${job.id}/approve`, { method: 'POST' })
|
||||||
|
if (err) return false
|
||||||
|
undoStack.value.push({ job, action: 'approve', prevStatus: job.status })
|
||||||
|
queue.value = queue.value.filter(j => j.id !== job.id)
|
||||||
|
_tickSession()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(job: Job) {
|
||||||
|
const { error: err } = await useApiFetch(`/api/jobs/${job.id}/reject`, { method: 'POST' })
|
||||||
|
if (err) return false
|
||||||
|
undoStack.value.push({ job, action: 'reject', prevStatus: job.status })
|
||||||
|
queue.value = queue.value.filter(j => j.id !== job.id)
|
||||||
|
_tickSession()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(job: Job) {
|
||||||
|
// Skip: move current card to back of queue without API call
|
||||||
|
queue.value = queue.value.filter(j => j.id !== job.id)
|
||||||
|
queue.value.push(job)
|
||||||
|
undoStack.value.push({ job, action: 'skip', prevStatus: job.status })
|
||||||
|
_tickSession()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
const entry = undoStack.value.pop()
|
||||||
|
if (!entry) return false
|
||||||
|
const { job, action } = entry
|
||||||
|
if (action === 'skip') {
|
||||||
|
// Was at back of queue — remove from wherever it landed, put at front
|
||||||
|
queue.value = queue.value.filter(j => j.id !== job.id)
|
||||||
|
queue.value.unshift(job)
|
||||||
|
} else {
|
||||||
|
await useApiFetch(`/api/jobs/${job.id}/revert`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: entry.prevStatus }),
|
||||||
|
})
|
||||||
|
queue.value.unshift(job)
|
||||||
|
}
|
||||||
|
sessionCount.value = Math.max(0, sessionCount.value - 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tickSession() {
|
||||||
|
sessionCount.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function markStoopAchieved() {
|
||||||
|
stoopAchieved.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSession() {
|
||||||
|
sessionStart.value = Date.now()
|
||||||
|
sessionCount.value = 0
|
||||||
|
stoopAchieved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
queue, listJobs, loading, error,
|
||||||
|
undoStack,
|
||||||
|
currentJob, remaining,
|
||||||
|
sessionCount, isStoopSpeed, stoopAchieved,
|
||||||
|
fetchQueue, fetchList,
|
||||||
|
approve, reject, skip, undo,
|
||||||
|
markStoopAchieved, resetSession,
|
||||||
|
}
|
||||||
|
})
|
||||||
22
web/src/stores/settings/data.test.ts
Normal file
22
web/src/stores/settings/data.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useDataStore } from './data'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useDataStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('initial backupPath is null', () => {
|
||||||
|
expect(useDataStore().backupPath).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createBackup() sets backupPath after success', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { path: 'data/backup.zip', file_count: 12, size_bytes: 1024 }, error: null })
|
||||||
|
const store = useDataStore()
|
||||||
|
await store.createBackup(false)
|
||||||
|
expect(store.backupPath).toBe('data/backup.zip')
|
||||||
|
})
|
||||||
|
})
|
||||||
30
web/src/stores/settings/data.ts
Normal file
30
web/src/stores/settings/data.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const useDataStore = defineStore('settings/data', () => {
|
||||||
|
const backupPath = ref<string | null>(null)
|
||||||
|
const backupFileCount = ref(0)
|
||||||
|
const backupSizeBytes = ref(0)
|
||||||
|
const creatingBackup = ref(false)
|
||||||
|
const restoring = ref(false)
|
||||||
|
const restoreResult = ref<{restored: string[]; skipped: string[]} | null>(null)
|
||||||
|
const backupError = ref<string | null>(null)
|
||||||
|
const restoreError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function createBackup(includeDb: boolean) {
|
||||||
|
creatingBackup.value = true
|
||||||
|
backupError.value = null
|
||||||
|
const { data, error } = await useApiFetch<{path: string; file_count: number; size_bytes: number}>(
|
||||||
|
'/api/settings/data/backup/create',
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_db: includeDb }) }
|
||||||
|
)
|
||||||
|
creatingBackup.value = false
|
||||||
|
if (error || !data) { backupError.value = 'Backup failed'; return }
|
||||||
|
backupPath.value = data.path
|
||||||
|
backupFileCount.value = data.file_count
|
||||||
|
backupSizeBytes.value = data.size_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return { backupPath, backupFileCount, backupSizeBytes, creatingBackup, restoring, restoreResult, backupError, restoreError, createBackup }
|
||||||
|
})
|
||||||
39
web/src/stores/settings/fineTune.test.ts
Normal file
39
web/src/stores/settings/fineTune.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useFineTuneStore } from './fineTune'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useFineTuneStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); vi.useFakeTimers() })
|
||||||
|
afterEach(() => { vi.useRealTimers() })
|
||||||
|
|
||||||
|
it('initial step is 1', () => {
|
||||||
|
expect(useFineTuneStore().step).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resetStep() returns to step 1', () => {
|
||||||
|
const store = useFineTuneStore()
|
||||||
|
store.step = 3
|
||||||
|
store.resetStep()
|
||||||
|
expect(store.step).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadStatus() sets inFlightJob when status is running', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { status: 'running', pairs_count: 10 }, error: null })
|
||||||
|
const store = useFineTuneStore()
|
||||||
|
await store.loadStatus()
|
||||||
|
expect(store.inFlightJob).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startPolling() calls loadStatus on interval', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { status: 'idle' }, error: null })
|
||||||
|
const store = useFineTuneStore()
|
||||||
|
store.startPolling()
|
||||||
|
await vi.advanceTimersByTimeAsync(4000)
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status')
|
||||||
|
store.stopPolling()
|
||||||
|
})
|
||||||
|
})
|
||||||
54
web/src/stores/settings/fineTune.ts
Normal file
54
web/src/stores/settings/fineTune.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
|
const step = ref(1)
|
||||||
|
const inFlightJob = ref(false)
|
||||||
|
const jobStatus = ref<string>('idle')
|
||||||
|
const pairsCount = ref(0)
|
||||||
|
const quotaRemaining = ref<number | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
let _pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function resetStep() { step.value = 1 }
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
const { data } = await useApiFetch<{ status: string; pairs_count: number; quota_remaining?: number }>('/api/settings/fine-tune/status')
|
||||||
|
if (!data) return
|
||||||
|
jobStatus.value = data.status
|
||||||
|
pairsCount.value = data.pairs_count ?? 0
|
||||||
|
quotaRemaining.value = data.quota_remaining ?? null
|
||||||
|
inFlightJob.value = ['queued', 'running'].includes(data.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
loadStatus()
|
||||||
|
_pollTimer = setInterval(loadStatus, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (_pollTimer !== null) { clearInterval(_pollTimer); _pollTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitJob() {
|
||||||
|
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
|
||||||
|
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
inFlightJob,
|
||||||
|
jobStatus,
|
||||||
|
pairsCount,
|
||||||
|
quotaRemaining,
|
||||||
|
uploading,
|
||||||
|
loading,
|
||||||
|
resetStep,
|
||||||
|
loadStatus,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
submitJob,
|
||||||
|
}
|
||||||
|
})
|
||||||
30
web/src/stores/settings/license.test.ts
Normal file
30
web/src/stores/settings/license.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useLicenseStore } from './license'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useLicenseStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('initial active is false', () => {
|
||||||
|
expect(useLicenseStore().active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activate() on success sets tier and active=true', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true, tier: 'paid' }, error: null })
|
||||||
|
const store = useLicenseStore()
|
||||||
|
await store.activate('CFG-PRNG-TEST-1234-5678')
|
||||||
|
expect(store.tier).toBe('paid')
|
||||||
|
expect(store.active).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activate() on failure sets activateError', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: false, error: 'Invalid key' }, error: null })
|
||||||
|
const store = useLicenseStore()
|
||||||
|
await store.activate('bad-key')
|
||||||
|
expect(store.activateError).toBe('Invalid key')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
web/src/stores/settings/license.ts
Normal file
51
web/src/stores/settings/license.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const useLicenseStore = defineStore('settings/license', () => {
|
||||||
|
const tier = ref<string>('free')
|
||||||
|
const licenseKey = ref<string | null>(null)
|
||||||
|
const active = ref(false)
|
||||||
|
const gracePeriodEnds = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const activating = ref(false)
|
||||||
|
const activateError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadLicense() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await useApiFetch<{tier: string; key: string | null; active: boolean; grace_period_ends?: string}>('/api/settings/license')
|
||||||
|
loading.value = false
|
||||||
|
if (!data) return
|
||||||
|
tier.value = data.tier
|
||||||
|
licenseKey.value = data.key
|
||||||
|
active.value = data.active
|
||||||
|
gracePeriodEnds.value = data.grace_period_ends ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate(key: string) {
|
||||||
|
activating.value = true
|
||||||
|
activateError.value = null
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; tier?: string; error?: string}>(
|
||||||
|
'/api/settings/license/activate',
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }
|
||||||
|
)
|
||||||
|
activating.value = false
|
||||||
|
if (!data) { activateError.value = 'Request failed'; return }
|
||||||
|
if (data.ok) {
|
||||||
|
active.value = true
|
||||||
|
tier.value = data.tier ?? tier.value
|
||||||
|
licenseKey.value = key
|
||||||
|
} else {
|
||||||
|
activateError.value = data.error ?? 'Activation failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate() {
|
||||||
|
await useApiFetch('/api/settings/license/deactivate', { method: 'POST' })
|
||||||
|
active.value = false
|
||||||
|
licenseKey.value = null
|
||||||
|
tier.value = 'free'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tier, licenseKey, active, gracePeriodEnds, loading, activating, activateError, loadLicense, activate, deactivate }
|
||||||
|
})
|
||||||
43
web/src/stores/settings/privacy.test.ts
Normal file
43
web/src/stores/settings/privacy.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { usePrivacyStore } from './privacy'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('usePrivacyStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('byokInfoDismissed is false by default', () => {
|
||||||
|
expect(usePrivacyStore().byokInfoDismissed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismissByokInfo() sets dismissed to true', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.dismissByokInfo()
|
||||||
|
expect(store.byokInfoDismissed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel is true when cloud backends configured and not dismissed', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.byokInfoDismissed = false
|
||||||
|
expect(store.showByokPanel).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel is false when dismissed', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.byokInfoDismissed = true
|
||||||
|
expect(store.showByokPanel).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel re-appears when new backend added after dismissal', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.dismissByokInfo()
|
||||||
|
store.activeCloudBackends = ['anthropic', 'openai']
|
||||||
|
expect(store.showByokPanel).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
64
web/src/stores/settings/privacy.ts
Normal file
64
web/src/stores/settings/privacy.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const usePrivacyStore = defineStore('settings/privacy', () => {
|
||||||
|
// Session-scoped BYOK panel state
|
||||||
|
const activeCloudBackends = ref<string[]>([])
|
||||||
|
const byokInfoDismissed = ref(false)
|
||||||
|
const dismissedForBackends = ref<string[]>([])
|
||||||
|
|
||||||
|
// Self-hosted privacy prefs
|
||||||
|
const telemetryOptIn = ref(false)
|
||||||
|
|
||||||
|
// Cloud privacy prefs
|
||||||
|
const masterOff = ref(false)
|
||||||
|
const usageEvents = ref(true)
|
||||||
|
const contentSharing = ref(false)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Panel shows if there are active cloud backends not yet covered by dismissal snapshot,
|
||||||
|
// or if byokInfoDismissed was set directly (e.g. loaded from server) and new backends haven't appeared
|
||||||
|
const showByokPanel = computed(() => {
|
||||||
|
if (activeCloudBackends.value.length === 0) return false
|
||||||
|
if (byokInfoDismissed.value && activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))) return false
|
||||||
|
if (byokInfoDismissed.value && dismissedForBackends.value.length === 0) return false
|
||||||
|
return !activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismissByokInfo() {
|
||||||
|
dismissedForBackends.value = [...activeCloudBackends.value]
|
||||||
|
byokInfoDismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrivacy() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/privacy')
|
||||||
|
loading.value = false
|
||||||
|
if (!data) return
|
||||||
|
telemetryOptIn.value = Boolean(data.telemetry_opt_in)
|
||||||
|
byokInfoDismissed.value = Boolean(data.byok_info_dismissed)
|
||||||
|
masterOff.value = Boolean(data.master_off)
|
||||||
|
usageEvents.value = data.usage_events !== false
|
||||||
|
contentSharing.value = Boolean(data.content_sharing)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrivacy(prefs: Record<string, unknown>) {
|
||||||
|
saving.value = true
|
||||||
|
await useApiFetch('/api/settings/privacy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(prefs),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCloudBackends, byokInfoDismissed, dismissedForBackends,
|
||||||
|
telemetryOptIn, masterOff, usageEvents, contentSharing,
|
||||||
|
loading, saving, showByokPanel,
|
||||||
|
dismissByokInfo, loadPrivacy, savePrivacy,
|
||||||
|
}
|
||||||
|
})
|
||||||
51
web/src/stores/settings/profile.test.ts
Normal file
51
web/src/stores/settings/profile.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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' }))
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/settings/resume/sync-identity',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets loadError when load fails', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'network', message: 'Network error' } })
|
||||||
|
const store = useProfileStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.loadError).toBe('Network error')
|
||||||
|
})
|
||||||
|
})
|
||||||
94
web/src/stores/settings/profile.ts
Normal file
94
web/src/stores/settings/profile.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export interface MissionPref { id: string; 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)
|
||||||
|
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/profile')
|
||||||
|
loading.value = false
|
||||||
|
if (error) {
|
||||||
|
loadError.value = error.kind === 'network' ? error.message : error.detail || 'Failed to load profile'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 Array<{ industry: string; note: string }>) ?? [])
|
||||||
|
.map((m) => ({ id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '' }))
|
||||||
|
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.map(({ industry, note }) => ({ industry, note })),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// fire-and-forget — identity sync failures don't block save
|
||||||
|
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, loadError,
|
||||||
|
load, save,
|
||||||
|
}
|
||||||
|
})
|
||||||
50
web/src/stores/settings/resume.test.ts
Normal file
50
web/src/stores/settings/resume.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('load() sets loadError on API error', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'Network error' } })
|
||||||
|
const store = useResumeStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.loadError).toBeTruthy()
|
||||||
|
expect(store.hasResume).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
125
web/src/stores/settings/resume.ts
Normal file
125
web/src/stores/settings/resume.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export interface WorkEntry {
|
||||||
|
id: string
|
||||||
|
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)
|
||||||
|
const loadError = 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
|
||||||
|
loadError.value = null
|
||||||
|
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
|
||||||
|
loading.value = false
|
||||||
|
if (error) {
|
||||||
|
loadError.value = error.kind === 'network' ? error.message : (error.detail || 'Failed to load resume')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!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 Omit<WorkEntry, 'id'>[]).map(e => ({ ...e, id: crypto.randomUUID() })) ?? []
|
||||||
|
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.map(({ id: _id, ...e }) => e),
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExperience() {
|
||||||
|
experience.value.push({ id: crypto.randomUUID(), title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExperience(idx: number) {
|
||||||
|
experience.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || arr.includes(trimmed)) return
|
||||||
|
arr.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||||
|
const idx = arr.indexOf(value)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasResume, loading, saving, saveError, loadError,
|
||||||
|
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,
|
||||||
|
addExperience, removeExperience, addTag, removeTag,
|
||||||
|
}
|
||||||
|
})
|
||||||
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' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
125
web/src/stores/settings/search.ts
Normal file
125
web/src/stores/settings/search.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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 = [...arr.value, 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]
|
||||||
|
arr.value = arr.value.filter(v => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSuggestion(type: 'title' | 'location', value: string) {
|
||||||
|
if (type === 'title') {
|
||||||
|
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
|
||||||
|
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
|
||||||
|
} else {
|
||||||
|
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
|
||||||
|
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBoard(name: string) {
|
||||||
|
job_boards.value = job_boards.value.map(b =>
|
||||||
|
b.name === name ? { ...b, enabled: !b.enabled } : b
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, toggleBoard,
|
||||||
|
}
|
||||||
|
})
|
||||||
83
web/src/stores/settings/system.test.ts
Normal file
83
web/src/stores/settings/system.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useSystemStore } from './system'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useSystemStore — BYOK gate', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('save() proceeds without modal when no cloud backends enabled', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||||
|
const store = useSystemStore()
|
||||||
|
store.backends = [{ id: 'ollama', enabled: true, priority: 1 }]
|
||||||
|
store.byokAcknowledged = []
|
||||||
|
await store.trySave()
|
||||||
|
expect(store.byokPending).toHaveLength(0)
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save() sets byokPending when new cloud backend enabled', async () => {
|
||||||
|
const store = useSystemStore()
|
||||||
|
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
|
||||||
|
store.byokAcknowledged = []
|
||||||
|
await store.trySave()
|
||||||
|
expect(store.byokPending).toContain('anthropic')
|
||||||
|
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save() skips modal for already-acknowledged backends', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||||
|
const store = useSystemStore()
|
||||||
|
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
|
||||||
|
store.byokAcknowledged = ['anthropic']
|
||||||
|
await store.trySave()
|
||||||
|
expect(store.byokPending).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirmByok() saves acknowledgment then commits LLM config', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||||
|
const store = useSystemStore()
|
||||||
|
store.byokPending = ['anthropic']
|
||||||
|
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
|
||||||
|
await store.confirmByok()
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm/byok-ack', expect.anything())
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirmByok() sets saveError and leaves modal open when ack POST fails', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: 'Network error' })
|
||||||
|
const store = useSystemStore()
|
||||||
|
store.byokPending = ['anthropic']
|
||||||
|
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
|
||||||
|
await store.confirmByok()
|
||||||
|
expect(store.saveError).toBeTruthy()
|
||||||
|
expect(store.byokPending).toContain('anthropic') // modal stays open
|
||||||
|
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelByok() clears pending and restores backends to pre-save state', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
|
||||||
|
const store = useSystemStore()
|
||||||
|
const original = [{ id: 'ollama', enabled: true, priority: 1 }]
|
||||||
|
store.backends = [...original]
|
||||||
|
await store.trySave() // captures snapshot, commits (no cloud backends)
|
||||||
|
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
|
||||||
|
store.byokPending = ['anthropic']
|
||||||
|
store.cancelByok()
|
||||||
|
expect(store.byokPending).toHaveLength(0)
|
||||||
|
expect(store.backends).toEqual(original)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useSystemStore — services', () => {
|
||||||
|
it('loadServices() populates services list', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: [{ name: 'ollama', port: 11434, running: true, note: '' }], error: null })
|
||||||
|
const store = useSystemStore()
|
||||||
|
await store.loadServices()
|
||||||
|
expect(store.services[0].name).toBe('ollama')
|
||||||
|
expect(store.services[0].running).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
246
web/src/stores/settings/system.ts
Normal file
246
web/src/stores/settings/system.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
|
||||||
|
|
||||||
|
export interface Backend { id: string; enabled: boolean; priority: number }
|
||||||
|
export interface Service { name: string; port: number; running: boolean; note: string }
|
||||||
|
export interface IntegrationField { key: string; label: string; type: string }
|
||||||
|
export interface Integration { id: string; name: string; connected: boolean; tier_required: string; fields: IntegrationField[] }
|
||||||
|
|
||||||
|
export const useSystemStore = defineStore('settings/system', () => {
|
||||||
|
const backends = ref<Backend[]>([])
|
||||||
|
const byokAcknowledged = ref<string[]>([])
|
||||||
|
const byokPending = ref<string[]>([])
|
||||||
|
// Private snapshot — NOT in return(). Closure-level only.
|
||||||
|
let _preSaveSnapshot: Backend[] = []
|
||||||
|
const saving = ref(false)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const services = ref<Service[]>([])
|
||||||
|
const emailConfig = ref<Record<string, unknown>>({})
|
||||||
|
const integrations = ref<Integration[]>([])
|
||||||
|
const serviceErrors = ref<Record<string, string>>({})
|
||||||
|
const emailSaving = ref(false)
|
||||||
|
const emailError = ref<string | null>(null)
|
||||||
|
// File paths + deployment
|
||||||
|
const filePaths = ref<Record<string, string>>({})
|
||||||
|
const deployConfig = ref<Record<string, unknown>>({})
|
||||||
|
const filePathsSaving = ref(false)
|
||||||
|
const deploySaving = ref(false)
|
||||||
|
const filePathsError = ref<string | null>(null)
|
||||||
|
const deployError = ref<string | null>(null)
|
||||||
|
// Integration test/connect results — keyed by integration id
|
||||||
|
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
|
||||||
|
|
||||||
|
async function loadLlm() {
|
||||||
|
loadError.value = null
|
||||||
|
const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm')
|
||||||
|
if (error) { loadError.value = 'Failed to load LLM config'; return }
|
||||||
|
if (!data) return
|
||||||
|
backends.value = data.backends ?? []
|
||||||
|
byokAcknowledged.value = data.byok_acknowledged ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trySave() {
|
||||||
|
_preSaveSnapshot = JSON.parse(JSON.stringify(backends.value))
|
||||||
|
const newlyEnabled = backends.value
|
||||||
|
.filter(b => CLOUD_BACKEND_IDS.includes(b.id) && b.enabled)
|
||||||
|
.map(b => b.id)
|
||||||
|
.filter(id => !byokAcknowledged.value.includes(id))
|
||||||
|
if (newlyEnabled.length > 0) {
|
||||||
|
byokPending.value = newlyEnabled
|
||||||
|
return // modal takes over
|
||||||
|
}
|
||||||
|
await _commitSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmByok() {
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = null
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/llm/byok-ack', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ backends: byokPending.value }),
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
saving.value = false
|
||||||
|
saveError.value = 'Failed to save acknowledgment — please try again.'
|
||||||
|
return // leave modal open, byokPending intact
|
||||||
|
}
|
||||||
|
byokAcknowledged.value = [...byokAcknowledged.value, ...byokPending.value]
|
||||||
|
byokPending.value = []
|
||||||
|
await _commitSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelByok() {
|
||||||
|
if (_preSaveSnapshot.length > 0) {
|
||||||
|
backends.value = JSON.parse(JSON.stringify(_preSaveSnapshot))
|
||||||
|
}
|
||||||
|
byokPending.value = []
|
||||||
|
_preSaveSnapshot = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _commitSave() {
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = null
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/llm', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ backends: backends.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (error) saveError.value = 'Save failed — please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServices() {
|
||||||
|
const { data } = await useApiFetch<Service[]>('/api/settings/system/services')
|
||||||
|
if (data) services.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startService(name: string) {
|
||||||
|
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
|
||||||
|
`/api/settings/system/services/${name}/start`, { method: 'POST' }
|
||||||
|
)
|
||||||
|
if (error || !data?.ok) {
|
||||||
|
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Start failed' }
|
||||||
|
} else {
|
||||||
|
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
|
||||||
|
await loadServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopService(name: string) {
|
||||||
|
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
|
||||||
|
`/api/settings/system/services/${name}/stop`, { method: 'POST' }
|
||||||
|
)
|
||||||
|
if (error || !data?.ok) {
|
||||||
|
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Stop failed' }
|
||||||
|
} else {
|
||||||
|
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
|
||||||
|
await loadServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmail() {
|
||||||
|
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/email')
|
||||||
|
if (data) emailConfig.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail() {
|
||||||
|
emailSaving.value = true
|
||||||
|
emailError.value = null
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/email', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(emailConfig.value),
|
||||||
|
})
|
||||||
|
emailSaving.value = false
|
||||||
|
if (error) emailError.value = 'Save failed — please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testEmail() {
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
|
||||||
|
'/api/settings/system/email/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(emailConfig.value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIntegrations() {
|
||||||
|
const { data } = await useApiFetch<Integration[]>('/api/settings/system/integrations')
|
||||||
|
if (data) integrations.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectIntegration(id: string, credentials: Record<string, string>) {
|
||||||
|
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
|
||||||
|
`/api/settings/system/integrations/${id}/connect`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
|
||||||
|
)
|
||||||
|
const result = error || !data?.ok
|
||||||
|
? { ok: false, error: data?.error ?? 'Connection failed' }
|
||||||
|
: { ok: true }
|
||||||
|
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||||
|
if (result.ok) await loadIntegrations()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testIntegration(id: string, credentials: Record<string, string>) {
|
||||||
|
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
|
||||||
|
`/api/settings/system/integrations/${id}/test`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
|
||||||
|
)
|
||||||
|
const result = { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) }
|
||||||
|
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectIntegration(id: string) {
|
||||||
|
const { error } = await useApiFetch(
|
||||||
|
`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' }
|
||||||
|
)
|
||||||
|
if (!error) await loadIntegrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmailWithPassword(payload: Record<string, unknown>) {
|
||||||
|
emailSaving.value = true
|
||||||
|
emailError.value = null
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/email', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
emailSaving.value = false
|
||||||
|
if (error) emailError.value = 'Save failed — please try again.'
|
||||||
|
else await loadEmail() // reload to get fresh password_set status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilePaths() {
|
||||||
|
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
|
||||||
|
if (data) filePaths.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFilePaths() {
|
||||||
|
filePathsSaving.value = true
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/paths', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(filePaths.value),
|
||||||
|
})
|
||||||
|
filePathsSaving.value = false
|
||||||
|
filePathsError.value = error ? 'Failed to save file paths.' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeployConfig() {
|
||||||
|
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/deploy')
|
||||||
|
if (data) deployConfig.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDeployConfig() {
|
||||||
|
deploySaving.value = true
|
||||||
|
const { error } = await useApiFetch('/api/settings/system/deploy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(deployConfig.value),
|
||||||
|
})
|
||||||
|
deploySaving.value = false
|
||||||
|
deployError.value = error ? 'Failed to save deployment config.' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backends, byokAcknowledged, byokPending, saving, saveError, loadError,
|
||||||
|
loadLlm, trySave, confirmByok, cancelByok,
|
||||||
|
services, emailConfig, integrations, integrationResults, serviceErrors, emailSaving, emailError,
|
||||||
|
filePaths, deployConfig, filePathsSaving, deploySaving, filePathsError, deployError,
|
||||||
|
loadServices, startService, stopService,
|
||||||
|
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
|
||||||
|
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
|
||||||
|
loadFilePaths, saveFilePaths,
|
||||||
|
loadDeployConfig, saveDeployConfig,
|
||||||
|
}
|
||||||
|
})
|
||||||
173
web/src/stores/survey.test.ts
Normal file
173
web/src/stores/survey.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useSurveyStore } from './survey'
|
||||||
|
|
||||||
|
vi.mock('../composables/useApi', () => ({
|
||||||
|
useApiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
describe('useSurveyStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor loads history and vision availability in parallel', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // history
|
||||||
|
.mockResolvedValueOnce({ data: { available: true }, error: null }) // vision
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
expect(store.history).toEqual([])
|
||||||
|
expect(store.visionAvailable).toBe(true)
|
||||||
|
expect(store.currentJobId).toBe(1)
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor clears state when called for a different job', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// Job 1
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'old' }], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { available: false }, error: null })
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
expect(store.history.length).toBe(1)
|
||||||
|
|
||||||
|
// Job 2 — state must be cleared before new data arrives
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||||
|
|
||||||
|
await store.fetchFor(2)
|
||||||
|
expect(store.history).toEqual([])
|
||||||
|
expect(store.currentJobId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('analyze stores result including mode and rawInput', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
|
data: { output: '1. B — reason', source: 'text_paste' },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||||
|
|
||||||
|
expect(store.analysis).not.toBeNull()
|
||||||
|
expect(store.analysis!.output).toBe('1. B — reason')
|
||||||
|
expect(store.analysis!.source).toBe('text_paste')
|
||||||
|
expect(store.analysis!.mode).toBe('quick')
|
||||||
|
expect(store.analysis!.rawInput).toBe('Q1: test')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('analyze sets error on failure', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
|
data: null,
|
||||||
|
error: { kind: 'http', status: 500, detail: 'LLM unavailable' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||||
|
|
||||||
|
expect(store.analysis).toBeNull()
|
||||||
|
expect(store.error).toBeTruthy()
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveResponse prepends to history and clears analysis', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// Setup: fetchFor
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
// Set analysis state manually (as if analyze() was called)
|
||||||
|
store.analysis = {
|
||||||
|
output: '1. B — reason',
|
||||||
|
source: 'text_paste',
|
||||||
|
mode: 'quick',
|
||||||
|
rawInput: 'Q1: test',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
|
data: { id: 42 },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
|
||||||
|
|
||||||
|
expect(store.history.length).toBe(1)
|
||||||
|
expect(store.history[0].id).toBe(42)
|
||||||
|
expect(store.history[0].llm_output).toBe('1. B — reason')
|
||||||
|
expect(store.analysis).toBeNull()
|
||||||
|
expect(store.saving).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveResponse sets error and preserves analysis on POST failure', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// Setup: fetchFor
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
// Set analysis state manually
|
||||||
|
store.analysis = {
|
||||||
|
output: '1. B — reason',
|
||||||
|
source: 'text_paste',
|
||||||
|
mode: 'quick',
|
||||||
|
rawInput: 'Q1: test',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save fails
|
||||||
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
|
data: null,
|
||||||
|
error: { kind: 'http', status: 500, detail: 'Internal Server Error' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
|
||||||
|
|
||||||
|
expect(store.saving).toBe(false)
|
||||||
|
expect(store.error).toBeTruthy()
|
||||||
|
expect(store.analysis).not.toBeNull()
|
||||||
|
expect(store.analysis!.output).toBe('1. B — reason')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear resets all state to initial values', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'test' }], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||||
|
|
||||||
|
const store = useSurveyStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
expect(store.history).toEqual([])
|
||||||
|
expect(store.analysis).toBeNull()
|
||||||
|
expect(store.visionAvailable).toBe(false)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.saving).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
expect(store.currentJobId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
157
web/src/stores/survey.ts
Normal file
157
web/src/stores/survey.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
const validSources = ['text_paste', 'screenshot'] as const
|
||||||
|
type ValidSource = typeof validSources[number]
|
||||||
|
function isValidSource(s: string): s is ValidSource {
|
||||||
|
return validSources.includes(s as ValidSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveyAnalysis {
|
||||||
|
output: string
|
||||||
|
source: 'text_paste' | 'screenshot'
|
||||||
|
mode: 'quick' | 'detailed'
|
||||||
|
rawInput: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveyResponse {
|
||||||
|
id: number
|
||||||
|
survey_name: string | null
|
||||||
|
mode: 'quick' | 'detailed'
|
||||||
|
source: string
|
||||||
|
raw_input: string | null
|
||||||
|
image_path: string | null
|
||||||
|
llm_output: string
|
||||||
|
reported_score: string | null
|
||||||
|
received_at: string | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSurveyStore = defineStore('survey', () => {
|
||||||
|
const analysis = ref<SurveyAnalysis | null>(null)
|
||||||
|
const history = ref<SurveyResponse[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const visionAvailable = ref(false)
|
||||||
|
const currentJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
async function fetchFor(jobId: number) {
|
||||||
|
if (jobId !== currentJobId.value) {
|
||||||
|
analysis.value = null
|
||||||
|
history.value = []
|
||||||
|
error.value = null
|
||||||
|
visionAvailable.value = false
|
||||||
|
currentJobId.value = jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [historyResult, visionResult] = await Promise.all([
|
||||||
|
useApiFetch<SurveyResponse[]>(`/api/jobs/${jobId}/survey/responses`),
|
||||||
|
useApiFetch<{ available: boolean }>('/api/vision/health'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (historyResult.error) {
|
||||||
|
error.value = 'Could not load survey history.'
|
||||||
|
} else {
|
||||||
|
history.value = historyResult.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
visionAvailable.value = visionResult.data?.available ?? false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyze(
|
||||||
|
jobId: number,
|
||||||
|
payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' }
|
||||||
|
) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const { data, error: fetchError } = await useApiFetch<{ output: string; source: string }>(
|
||||||
|
`/api/jobs/${jobId}/survey/analyze`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(payload) }
|
||||||
|
)
|
||||||
|
loading.value = false
|
||||||
|
if (fetchError || !data) {
|
||||||
|
error.value = 'Analysis failed. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysis.value = {
|
||||||
|
output: data.output,
|
||||||
|
source: isValidSource(data.source) ? data.source : 'text_paste',
|
||||||
|
mode: payload.mode,
|
||||||
|
rawInput: payload.text ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResponse(
|
||||||
|
jobId: number,
|
||||||
|
args: { surveyName: string; reportedScore: string; image_b64?: string }
|
||||||
|
) {
|
||||||
|
if (!analysis.value) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
const body = {
|
||||||
|
survey_name: args.surveyName || undefined,
|
||||||
|
mode: analysis.value.mode,
|
||||||
|
source: analysis.value.source,
|
||||||
|
raw_input: analysis.value.rawInput,
|
||||||
|
image_b64: args.image_b64,
|
||||||
|
llm_output: analysis.value.output,
|
||||||
|
reported_score: args.reportedScore || undefined,
|
||||||
|
}
|
||||||
|
const { data, error: fetchError } = await useApiFetch<{ id: number }>(
|
||||||
|
`/api/jobs/${jobId}/survey/responses`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(body) }
|
||||||
|
)
|
||||||
|
saving.value = false
|
||||||
|
if (fetchError || !data) {
|
||||||
|
error.value = 'Save failed. Your analysis is preserved — try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Prepend the saved response to history
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const saved: SurveyResponse = {
|
||||||
|
id: data.id,
|
||||||
|
survey_name: args.surveyName || null,
|
||||||
|
mode: analysis.value.mode,
|
||||||
|
source: analysis.value.source,
|
||||||
|
raw_input: analysis.value.rawInput,
|
||||||
|
image_path: null,
|
||||||
|
llm_output: analysis.value.output,
|
||||||
|
reported_score: args.reportedScore || null,
|
||||||
|
received_at: now,
|
||||||
|
created_at: now,
|
||||||
|
}
|
||||||
|
history.value = [saved, ...history.value]
|
||||||
|
analysis.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
analysis.value = null
|
||||||
|
history.value = []
|
||||||
|
loading.value = false
|
||||||
|
saving.value = false
|
||||||
|
error.value = null
|
||||||
|
visionAvailable.value = false
|
||||||
|
currentJobId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
analysis,
|
||||||
|
history,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
visionAvailable,
|
||||||
|
currentJobId,
|
||||||
|
fetchFor,
|
||||||
|
analyze,
|
||||||
|
saveResponse,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
35
web/src/test-setup.ts
Normal file
35
web/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// jsdom does not implement window.matchMedia — stub it so useMotion and other
|
||||||
|
// composables that check prefers-reduced-motion can import without throwing.
|
||||||
|
// Gotcha #12.
|
||||||
|
if (typeof window !== 'undefined' && !window.matchMedia) {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigator.vibrate not in jsdom — stub so useHaptics doesn't throw. Gotcha #9.
|
||||||
|
if (typeof window !== 'undefined' && !('vibrate' in window.navigator)) {
|
||||||
|
Object.defineProperty(window.navigator, 'vibrate', {
|
||||||
|
writable: true,
|
||||||
|
value: () => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeObserver not in jsdom — stub if any component uses it.
|
||||||
|
if (typeof window !== 'undefined' && !window.ResizeObserver) {
|
||||||
|
window.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
561
web/src/views/ApplyView.vue
Normal file
561
web/src/views/ApplyView.vue
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
<template>
|
||||||
|
<!-- ── Mobile: full-width list ──────────────────────────────────── -->
|
||||||
|
<div v-if="isMobile" class="apply-list">
|
||||||
|
<header class="apply-list__header">
|
||||||
|
<h1 class="apply-list__title">Apply</h1>
|
||||||
|
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="apply-list__loading" aria-live="polite">
|
||||||
|
<span class="spinner" aria-hidden="true" />
|
||||||
|
<span>Loading approved jobs…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
|
||||||
|
<span aria-hidden="true" class="empty-icon">📋</span>
|
||||||
|
<h2 class="empty-title">No approved jobs yet</h2>
|
||||||
|
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
|
||||||
|
<RouterLink to="/review" class="empty-cta">Go to Job Review →</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="apply-list__jobs" role="list">
|
||||||
|
<li v-for="job in jobs" :key="job.id">
|
||||||
|
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
|
||||||
|
<div class="job-row__main">
|
||||||
|
<div class="job-row__badges">
|
||||||
|
<span v-if="job.match_score !== null" class="score-badge" :class="scoreBadgeClass(job.match_score)">
|
||||||
|
{{ job.match_score }}%
|
||||||
|
</span>
|
||||||
|
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||||
|
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done">✓ Draft</span>
|
||||||
|
<span v-else class="cl-badge cl-badge--pending">○ No draft</span>
|
||||||
|
</div>
|
||||||
|
<span class="job-row__title">{{ job.title }}</span>
|
||||||
|
<span class="job-row__company">
|
||||||
|
{{ job.company }}
|
||||||
|
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
|
||||||
|
<span v-if="job.location">{{ job.location }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-row__meta">
|
||||||
|
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
|
||||||
|
<span class="job-row__arrow" aria-hidden="true">›</span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Desktop: split pane ─────────────────────────────────────── -->
|
||||||
|
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
||||||
|
<!-- Left: narrow job list -->
|
||||||
|
<div class="apply-split__list">
|
||||||
|
<div class="split-list__header">
|
||||||
|
<h1 class="split-list__title">Apply</h1>
|
||||||
|
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
|
||||||
|
📬 {{ coverLetterCount }} today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="split-list__loading" aria-live="polite">
|
||||||
|
<span class="spinner" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="jobs.length === 0" class="split-list__empty" role="status">
|
||||||
|
<span>No approved jobs yet.</span>
|
||||||
|
<RouterLink to="/review" class="split-list__cta">Go to Job Review →</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="split-list__jobs" role="list">
|
||||||
|
<li v-for="job in jobs" :key="job.id">
|
||||||
|
<button
|
||||||
|
class="narrow-row"
|
||||||
|
:class="{ 'narrow-row--selected': job.id === selectedJobId }"
|
||||||
|
:aria-label="`Open ${job.title} at ${job.company}`"
|
||||||
|
:aria-pressed="job.id === selectedJobId"
|
||||||
|
@click="selectJob(job.id)"
|
||||||
|
>
|
||||||
|
<div class="narrow-row__top">
|
||||||
|
<span class="narrow-row__title">{{ job.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="job.match_score !== null"
|
||||||
|
class="score-badge"
|
||||||
|
:class="scoreBadgeClass(job.match_score)"
|
||||||
|
>{{ job.match_score }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="narrow-row__company">
|
||||||
|
{{ job.company }}<span v-if="job.has_cover_letter" class="narrow-row__cl-tick"> ✓</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: workspace panel -->
|
||||||
|
<div class="apply-split__panel" aria-live="polite">
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="selectedJobId === null" class="split-panel__empty">
|
||||||
|
<span aria-hidden="true" style="font-size: 2rem;">🦅</span>
|
||||||
|
<p>Select a job to open the workspace</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workspace -->
|
||||||
|
<ApplyWorkspace
|
||||||
|
v-else
|
||||||
|
:key="selectedJobId"
|
||||||
|
:job-id="selectedJobId"
|
||||||
|
@job-removed="onJobRemoved"
|
||||||
|
@cover-letter-generated="onCoverLetterGenerated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Speed Demon canvas (hidden until triggered) -->
|
||||||
|
<canvas ref="birdCanvas" class="bird-canvas" aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="toast" class="split-toast" role="status" aria-live="polite">{{ toast }}</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
|
||||||
|
|
||||||
|
// ── Responsive ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isMobile = ref(window.innerWidth < 1024)
|
||||||
|
|
||||||
|
let _mq: MediaQueryList | null = null
|
||||||
|
let _mqHandler: ((e: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
_mq = window.matchMedia('(max-width: 1023px)')
|
||||||
|
_mqHandler = (e: MediaQueryListEvent) => { isMobile.value = e.matches }
|
||||||
|
_mq.addEventListener('change', _mqHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (_mq && _mqHandler) _mq.removeEventListener('change', _mqHandler)
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Job list data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ApprovedJob {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
location: string | null
|
||||||
|
is_remote: boolean
|
||||||
|
salary: string | null
|
||||||
|
match_score: number | null
|
||||||
|
has_cover_letter: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = ref<ApprovedJob[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function fetchJobs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await useApiFetch<ApprovedJob[]>(
|
||||||
|
'/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter'
|
||||||
|
)
|
||||||
|
if (data) jobs.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchJobs)
|
||||||
|
|
||||||
|
// ── Score badge — 4-tier ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scoreBadgeClass(score: number | null): string {
|
||||||
|
if (score === null) return ''
|
||||||
|
if (score >= 70) return 'score-badge--high'
|
||||||
|
if (score >= 50) return 'score-badge--mid-high'
|
||||||
|
if (score >= 30) return 'score-badge--mid'
|
||||||
|
return 'score-badge--low'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Speed Demon: track up to 5 most-recent click timestamps
|
||||||
|
// Plain let (not ref) — never bound to template, no reactivity needed
|
||||||
|
let recentClicks: number[] = []
|
||||||
|
|
||||||
|
function selectJob(id: number) {
|
||||||
|
selectedJobId.value = id
|
||||||
|
|
||||||
|
// Speed Demon tracking
|
||||||
|
const now = Date.now()
|
||||||
|
recentClicks = [...recentClicks, now].slice(-5)
|
||||||
|
if (
|
||||||
|
recentClicks.length === 5 &&
|
||||||
|
recentClicks[4] - recentClicks[0] < 3000
|
||||||
|
) {
|
||||||
|
fireSpeedDemon()
|
||||||
|
recentClicks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job removed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function onJobRemoved() {
|
||||||
|
selectedJobId.value = null
|
||||||
|
await fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marathon counter ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const coverLetterCount = ref(0)
|
||||||
|
|
||||||
|
function onCoverLetterGenerated() {
|
||||||
|
coverLetterCount.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toast = ref<string | null>(null)
|
||||||
|
let toastTimer = 0
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toast.value = msg
|
||||||
|
toastTimer = window.setTimeout(() => { toast.value = null }, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Easter egg: Speed Demon 🦅 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const birdCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const splitEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function fireSpeedDemon() {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
showToast('🦅 You\'re on the hunt!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = birdCanvas.value
|
||||||
|
const parent = splitEl.value
|
||||||
|
if (!canvas || !parent) return
|
||||||
|
|
||||||
|
const rect = parent.getBoundingClientRect()
|
||||||
|
canvas.width = rect.width
|
||||||
|
canvas.height = rect.height
|
||||||
|
canvas.style.display = 'block'
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const FRAMES = 36 // 600ms at 60fps
|
||||||
|
const startY = rect.height * 0.35
|
||||||
|
let frame = 0
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
const progress = frame / FRAMES
|
||||||
|
const x = progress * (canvas.width + 60) - 30
|
||||||
|
const y = startY + Math.sin(progress * Math.PI) * -30
|
||||||
|
ctx.font = '2rem serif'
|
||||||
|
ctx.globalAlpha = frame < 4 ? frame / 4 : frame > FRAMES - 4 ? (FRAMES - frame) / 4 : 1
|
||||||
|
ctx.fillText('🦅', x, y)
|
||||||
|
frame++
|
||||||
|
if (frame <= FRAMES) {
|
||||||
|
requestAnimationFrame(draw)
|
||||||
|
} else {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
canvas.style.display = 'none'
|
||||||
|
showToast('🦅 You\'re on the hunt!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Shared: spinner ─────────────────────────────────────────────── */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Shared: score badges ────────────────────────────────────────── */
|
||||||
|
.score-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
|
||||||
|
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
|
||||||
|
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
|
||||||
|
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
|
||||||
|
|
||||||
|
.remote-badge {
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile list (unchanged from original) ───────────────────────── */
|
||||||
|
.apply-list {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
|
.apply-list__title { font-family: var(--font-display); font-size: var(--text-2xl); color: var(--app-primary); }
|
||||||
|
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
|
||||||
|
.apply-list__loading { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-12); color: var(--color-text-muted); font-size: var(--text-sm); justify-content: center; }
|
||||||
|
.apply-list__empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding: var(--space-16) var(--space-8); text-align: center; }
|
||||||
|
.empty-icon { font-size: 3rem; }
|
||||||
|
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
|
||||||
|
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
|
||||||
|
.empty-cta { margin-top: var(--space-2); color: var(--app-primary); font-size: var(--text-sm); font-weight: 600; text-decoration: none; }
|
||||||
|
.empty-cta:hover { opacity: 0.7; }
|
||||||
|
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.job-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-5); background: var(--color-surface-raised); border: 1px solid var(--color-border-light); border-radius: var(--radius-lg); text-decoration: none; min-height: 72px; transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease; }
|
||||||
|
.job-row:hover { border-color: var(--app-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
|
||||||
|
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
|
||||||
|
.job-row__badges { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: 2px; }
|
||||||
|
.job-row__title { font-size: var(--text-sm); font-weight: 700; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.job-row__company { font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.job-row__meta { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
|
||||||
|
.job-row__salary { font-size: var(--text-xs); color: var(--color-success); font-weight: 600; white-space: nowrap; }
|
||||||
|
.job-row__arrow { font-size: 1.25rem; color: var(--color-text-muted); line-height: 1; }
|
||||||
|
.job-row__sep { color: var(--color-border); }
|
||||||
|
.cl-badge { padding: 1px var(--space-2); border-radius: 999px; font-size: var(--text-xs); font-weight: 600; }
|
||||||
|
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
|
||||||
|
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* ── Desktop split pane ──────────────────────────────────────────── */
|
||||||
|
.apply-split {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28% 0fr;
|
||||||
|
height: calc(100vh - var(--nav-height, 4rem));
|
||||||
|
overflow: hidden;
|
||||||
|
transition: grid-template-columns 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.apply-split { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-split.has-selection {
|
||||||
|
grid-template-columns: 28% 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left: narrow list column ────────────────────────────────────── */
|
||||||
|
.apply-split__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--color-border-light);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-5) var(--space-4) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marathon badge */
|
||||||
|
.marathon-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(224, 104, 32, 0.12);
|
||||||
|
color: var(--app-accent);
|
||||||
|
border: 1px solid rgba(224, 104, 32, 0.3);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__cta {
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-list__jobs {
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Narrow row ──────────────────────────────────────────────────── */
|
||||||
|
.narrow-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 100ms ease, border-left-color 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row:hover {
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
border-left-color: rgba(43, 108, 176, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row--selected {
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
/* color-mix enhancement for supported browsers */
|
||||||
|
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised));
|
||||||
|
border-left-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row__company {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow-row__cl-tick {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Right: workspace panel ──────────────────────────────────────── */
|
||||||
|
.apply-split__panel {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: clip; /* clip prevents BFC side-effect of hidden; also lets position:sticky work inside */
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease 100ms; /* 100ms delay so content fades in after column expands */
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-split.has-selection .apply-split__panel {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.apply-split__panel { transition: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Easter egg: Speed Demon canvas ─────────────────────────────── */
|
||||||
|
.bird-canvas {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast ───────────────────────────────────────────────────────── */
|
||||||
|
.split-toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-6);
|
||||||
|
right: var(--space-6);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 100;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
|
||||||
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
|
||||||
|
|
||||||
|
/* ── Mobile overrides ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.apply-list { padding: var(--space-4); gap: var(--space-4); }
|
||||||
|
.apply-list__title { font-size: var(--text-xl); }
|
||||||
|
.job-row { padding: var(--space-3) var(--space-4); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
web/src/views/ApplyWorkspaceView.vue
Normal file
20
web/src/views/ApplyWorkspaceView.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
@cover-letter-generated is intentionally not forwarded here.
|
||||||
|
The Marathon badge lives in ApplyView.vue (desktop split-pane only) — the full-page route is mobile-only.
|
||||||
|
-->
|
||||||
|
<ApplyWorkspace
|
||||||
|
:job-id="jobId"
|
||||||
|
@job-removed="router.push('/apply')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const jobId = computed(() => Number(route.params.id))
|
||||||
|
</script>
|
||||||
404
web/src/views/DigestView.vue
Normal file
404
web/src/views/DigestView.vue
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useDigestStore, type DigestEntry, type DigestLink } from '../stores/digest'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
const store = useDigestStore()
|
||||||
|
|
||||||
|
// Per-entry state keyed by DigestEntry.id
|
||||||
|
const expandedIds = ref<Record<number, boolean>>({})
|
||||||
|
const linkResults = ref<Record<number, DigestLink[]>>({})
|
||||||
|
const selectedUrls = ref<Record<number, Set<string>>>({})
|
||||||
|
const queueResult = ref<Record<number, { queued: number; skipped: number } | null>>({})
|
||||||
|
const extracting = ref<Record<number, boolean>>({})
|
||||||
|
const queuing = ref<Record<number, boolean>>({})
|
||||||
|
const entryError = ref<Record<number, string | null>>({})
|
||||||
|
|
||||||
|
onMounted(() => store.fetchAll())
|
||||||
|
|
||||||
|
function toggleExpand(id: number) {
|
||||||
|
expandedIds.value = { ...expandedIds.value, [id]: !expandedIds.value[id] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spread-copy pattern — same as expandedSignalIds in InterviewCard, safe for Vue 3 reactivity
|
||||||
|
function toggleUrl(entryId: number, url: string) {
|
||||||
|
const prev = selectedUrls.value[entryId] ?? new Set<string>()
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(url) ? next.delete(url) : next.add(url)
|
||||||
|
selectedUrls.value = { ...selectedUrls.value, [entryId]: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCount(id: number) {
|
||||||
|
return selectedUrls.value[id]?.size ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobLinks(id: number): DigestLink[] {
|
||||||
|
return (linkResults.value[id] ?? []).filter(l => l.score >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherLinks(id: number): DigestLink[] {
|
||||||
|
return (linkResults.value[id] ?? []).filter(l => l.score < 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractLinks(entry: DigestEntry) {
|
||||||
|
extracting.value = { ...extracting.value, [entry.id]: true }
|
||||||
|
const { data, error: err } = await useApiFetch<{ links: DigestLink[] }>(
|
||||||
|
`/api/digest-queue/${entry.id}/extract-links`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
extracting.value = { ...extracting.value, [entry.id]: false }
|
||||||
|
if (err) {
|
||||||
|
entryError.value = { ...entryError.value, [entry.id]: 'Could not extract links — try again' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entryError.value = { ...entryError.value, [entry.id]: null }
|
||||||
|
if (!data) return
|
||||||
|
linkResults.value = { ...linkResults.value, [entry.id]: data.links }
|
||||||
|
expandedIds.value = { ...expandedIds.value, [entry.id]: true }
|
||||||
|
// Pre-check job-likely links (score >= 2)
|
||||||
|
const preChecked = new Set(data.links.filter(l => l.score >= 2).map(l => l.url))
|
||||||
|
selectedUrls.value = { ...selectedUrls.value, [entry.id]: preChecked }
|
||||||
|
queueResult.value = { ...queueResult.value, [entry.id]: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueJobs(entry: DigestEntry) {
|
||||||
|
const urls = [...(selectedUrls.value[entry.id] ?? [])]
|
||||||
|
if (!urls.length) return
|
||||||
|
queuing.value = { ...queuing.value, [entry.id]: true }
|
||||||
|
const { data, error: err } = await useApiFetch<{ queued: number; skipped: number }>(
|
||||||
|
`/api/digest-queue/${entry.id}/queue-jobs`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ urls }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
queuing.value = { ...queuing.value, [entry.id]: false }
|
||||||
|
if (err) {
|
||||||
|
entryError.value = { ...entryError.value, [entry.id]: 'Could not queue jobs — try again' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entryError.value = { ...entryError.value, [entry.id]: null }
|
||||||
|
if (!data) return
|
||||||
|
queueResult.value = { ...queueResult.value, [entry.id]: data }
|
||||||
|
linkResults.value = { ...linkResults.value, [entry.id]: [] }
|
||||||
|
expandedIds.value = { ...expandedIds.value, [entry.id]: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="digest-view">
|
||||||
|
<h1 class="digest-heading">📰 Digest Queue</h1>
|
||||||
|
|
||||||
|
<div v-if="store.entries.length === 0" class="digest-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<p>No digest emails queued.</p>
|
||||||
|
<p class="empty-hint">When you mark an email as 📰 Digest, it appears here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="digest-list">
|
||||||
|
<div v-for="entry in store.entries" :key="entry.id" class="digest-entry">
|
||||||
|
|
||||||
|
<!-- Entry header row -->
|
||||||
|
<div
|
||||||
|
class="entry-header"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="!!expandedIds[entry.id]"
|
||||||
|
:aria-label="`Toggle ${entry.subject}`"
|
||||||
|
@click="toggleExpand(entry.id)"
|
||||||
|
@keydown.enter.prevent="toggleExpand(entry.id)"
|
||||||
|
@keydown.space.prevent="toggleExpand(entry.id)"
|
||||||
|
>
|
||||||
|
<span class="entry-toggle" aria-hidden="true">{{ expandedIds[entry.id] ? '▾' : '▸' }}</span>
|
||||||
|
<div class="entry-meta">
|
||||||
|
<span class="entry-subject">{{ entry.subject }}</span>
|
||||||
|
<span class="entry-from">
|
||||||
|
<template v-if="entry.from_addr">From: {{ entry.from_addr }} · </template>
|
||||||
|
{{ formatDate(entry.received_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-actions" @click.stop>
|
||||||
|
<button
|
||||||
|
class="btn-extract"
|
||||||
|
:disabled="extracting[entry.id]"
|
||||||
|
:aria-label="linkResults[entry.id]?.length ? 'Re-extract links' : 'Extract job links'"
|
||||||
|
@click="extractLinks(entry)"
|
||||||
|
>
|
||||||
|
{{ linkResults[entry.id]?.length ? 'Re-extract' : 'Extract' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-dismiss"
|
||||||
|
aria-label="Remove from digest queue"
|
||||||
|
@click="store.remove(entry.id)"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-entry error -->
|
||||||
|
<div v-if="entryError[entry.id]" class="entry-error">{{ entryError[entry.id] }}</div>
|
||||||
|
|
||||||
|
<!-- Post-queue confirmation -->
|
||||||
|
<div v-if="queueResult[entry.id]" class="queue-result">
|
||||||
|
✅ {{ queueResult[entry.id]!.queued }}
|
||||||
|
job{{ queueResult[entry.id]!.queued !== 1 ? 's' : '' }} queued for review<template
|
||||||
|
v-if="queueResult[entry.id]!.skipped > 0"
|
||||||
|
>, {{ queueResult[entry.id]!.skipped }} skipped (already in pipeline)</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded: link list -->
|
||||||
|
<template v-if="expandedIds[entry.id]">
|
||||||
|
<div v-if="extracting[entry.id]" class="entry-status">Extracting links…</div>
|
||||||
|
|
||||||
|
<div v-else-if="linkResults[entry.id] !== undefined && !linkResults[entry.id]!.length" class="entry-status">
|
||||||
|
No job links found in this email.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="linkResults[entry.id]?.length" class="entry-links">
|
||||||
|
<!-- Job-likely links (score ≥ 2), pre-checked -->
|
||||||
|
<div class="link-group">
|
||||||
|
<label
|
||||||
|
v-for="link in jobLinks(entry.id)"
|
||||||
|
:key="link.url"
|
||||||
|
class="link-row"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="link-check"
|
||||||
|
:checked="selectedUrls[entry.id]?.has(link.url)"
|
||||||
|
@change="toggleUrl(entry.id, link.url)"
|
||||||
|
/>
|
||||||
|
<div class="link-text">
|
||||||
|
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
|
||||||
|
<span class="link-url">{{ link.url }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Other links (score = 1), unchecked -->
|
||||||
|
<template v-if="otherLinks(entry.id).length">
|
||||||
|
<div class="link-divider">Other links</div>
|
||||||
|
<div class="link-group">
|
||||||
|
<label
|
||||||
|
v-for="link in otherLinks(entry.id)"
|
||||||
|
:key="link.url"
|
||||||
|
class="link-row link-row--other"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="link-check"
|
||||||
|
:checked="selectedUrls[entry.id]?.has(link.url)"
|
||||||
|
@change="toggleUrl(entry.id, link.url)"
|
||||||
|
/>
|
||||||
|
<div class="link-text">
|
||||||
|
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
|
||||||
|
<span class="link-url">{{ link.url }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-queue"
|
||||||
|
:disabled="selectedCount(entry.id) === 0 || queuing[entry.id]"
|
||||||
|
@click="queueJobs(entry)"
|
||||||
|
>
|
||||||
|
Queue {{ selectedCount(entry.id) > 0 ? selectedCount(entry.id) + ' ' : '' }}selected →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.digest-view {
|
||||||
|
padding: var(--space-6);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.digest-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-16) var(--space-8);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.empty-bird { font-size: 2.5rem; display: block; margin-bottom: var(--space-4); }
|
||||||
|
.empty-hint { font-size: 0.875rem; margin-top: var(--space-2); }
|
||||||
|
|
||||||
|
/* Entry list */
|
||||||
|
.digest-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
|
||||||
|
.digest-entry {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry header */
|
||||||
|
.entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.entry-header:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.entry-toggle { color: var(--color-text-muted); font-size: 0.9rem; flex-shrink: 0; padding-top: 2px; }
|
||||||
|
|
||||||
|
.entry-meta { flex: 1; min-width: 0; }
|
||||||
|
.entry-subject {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.entry-from { display: block; font-size: 0.75rem; color: var(--color-text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.entry-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.btn-extract {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.btn-extract:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
.btn-extract:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.btn-dismiss {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.btn-dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
|
||||||
|
|
||||||
|
/* Queue result */
|
||||||
|
.queue-result {
|
||||||
|
margin: 0 var(--space-4) var(--space-3);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
background: color-mix(in srgb, var(--color-success) 10%, var(--color-surface-raised));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.entry-error {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface-raised));
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 var(--space-4) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status messages */
|
||||||
|
.entry-status {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link list */
|
||||||
|
.entry-links { padding: 0 var(--space-4) var(--space-4); }
|
||||||
|
.link-group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-surface);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.link-row:hover { background: var(--color-surface-alt); }
|
||||||
|
.link-row--other { opacity: 0.8; }
|
||||||
|
|
||||||
|
.link-check { flex-shrink: 0; margin-top: 3px; accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
|
||||||
|
.link-text { min-width: 0; flex: 1; }
|
||||||
|
.link-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.link-url {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-divider {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-3) 0 var(--space-2);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-queue {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.btn-queue:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||||
|
.btn-queue:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.digest-view { padding: var(--space-4); }
|
||||||
|
.entry-subject { font-size: 0.85rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
597
web/src/views/HomeView.vue
Normal file
597
web/src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="home__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="home__greeting">
|
||||||
|
{{ greeting }}
|
||||||
|
<span v-if="isMidnight" aria-label="Late night session">🌙</span>
|
||||||
|
</h1>
|
||||||
|
<p class="home__subtitle">Discover → Review → Apply</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Metric cards -->
|
||||||
|
<section class="home__metrics" aria-label="Pipeline overview">
|
||||||
|
<RouterLink
|
||||||
|
v-for="metric in metrics"
|
||||||
|
:key="metric.status"
|
||||||
|
:to="metric.link"
|
||||||
|
class="metric-card"
|
||||||
|
:class="`metric-card--${metric.status}`"
|
||||||
|
:aria-label="`${metric.count ?? 0} ${metric.label} jobs`"
|
||||||
|
>
|
||||||
|
<span class="metric-card__count" aria-hidden="true">
|
||||||
|
{{ store.loading ? '—' : (metric.count ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<span class="metric-card__label">{{ metric.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Primary workflow -->
|
||||||
|
<section class="home__section" aria-labelledby="workflow-heading">
|
||||||
|
<h2 id="workflow-heading" class="home__section-title">Primary Workflow</h2>
|
||||||
|
<div class="home__actions">
|
||||||
|
<WorkflowButton
|
||||||
|
emoji="🚀"
|
||||||
|
label="Run Discovery"
|
||||||
|
description="Scan job boards for new listings"
|
||||||
|
:loading="taskRunning === 'discovery'"
|
||||||
|
@click="runDiscovery"
|
||||||
|
/>
|
||||||
|
<WorkflowButton
|
||||||
|
emoji="📧"
|
||||||
|
label="Sync Emails"
|
||||||
|
description="Fetch and classify inbox"
|
||||||
|
:loading="taskRunning === 'email'"
|
||||||
|
@click="syncEmails"
|
||||||
|
/>
|
||||||
|
<WorkflowButton
|
||||||
|
emoji="📊"
|
||||||
|
label="Score Unscored"
|
||||||
|
description="Run match scoring on new jobs"
|
||||||
|
:loading="taskRunning === 'score'"
|
||||||
|
@click="scoreUnscored"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="unsyncedCount > 0"
|
||||||
|
class="sync-banner"
|
||||||
|
:disabled="taskRunning === 'sync'"
|
||||||
|
:aria-busy="taskRunning === 'sync'"
|
||||||
|
@click="syncIntegration"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">📤</span>
|
||||||
|
<span>
|
||||||
|
Sync {{ unsyncedCount }} approved {{ unsyncedCount === 1 ? 'job' : 'jobs' }}
|
||||||
|
→ {{ integrationName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="taskRunning === 'sync'" class="spinner" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auto-enrichment status -->
|
||||||
|
<section v-if="store.status?.enrichment_enabled" class="home__section">
|
||||||
|
<div class="enrichment-row" role="status" aria-live="polite">
|
||||||
|
<span class="enrichment-row__dot" :class="enrichmentDotClass" aria-hidden="true" />
|
||||||
|
<span class="enrichment-row__text">
|
||||||
|
{{ store.status?.enrichment_last_run
|
||||||
|
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
|
||||||
|
: 'Auto-enrichment active' }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-ghost btn-ghost--sm" @click="runEnrich">Run Now</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Backlog management -->
|
||||||
|
<section v-if="showBacklog" class="home__section" aria-labelledby="backlog-heading">
|
||||||
|
<h2 id="backlog-heading" class="home__section-title">Backlog Management</h2>
|
||||||
|
<p class="home__section-desc">
|
||||||
|
You have
|
||||||
|
<strong>{{ store.counts?.pending ?? 0 }} pending</strong>
|
||||||
|
and
|
||||||
|
<strong>{{ store.counts?.approved ?? 0 }} approved</strong>
|
||||||
|
listings.
|
||||||
|
</p>
|
||||||
|
<div class="home__actions home__actions--secondary">
|
||||||
|
<button
|
||||||
|
v-if="(store.counts?.pending ?? 0) > 0"
|
||||||
|
class="action-btn action-btn--secondary"
|
||||||
|
@click="archiveByStatus(['pending'])"
|
||||||
|
>
|
||||||
|
📦 Archive Pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="(store.counts?.rejected ?? 0) > 0"
|
||||||
|
class="action-btn action-btn--secondary"
|
||||||
|
@click="archiveByStatus(['rejected'])"
|
||||||
|
>
|
||||||
|
📦 Archive Rejected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="(store.counts?.approved ?? 0) > 0"
|
||||||
|
class="action-btn action-btn--secondary"
|
||||||
|
@click="archiveByStatus(['approved'])"
|
||||||
|
>
|
||||||
|
📦 Archive Approved (unapplied)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add jobs by URL -->
|
||||||
|
<section class="home__section" aria-labelledby="add-heading">
|
||||||
|
<h2 id="add-heading" class="home__section-title">Add Jobs by URL</h2>
|
||||||
|
<div class="add-jobs">
|
||||||
|
<div class="add-jobs__tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="addTab === 'url'"
|
||||||
|
class="add-jobs__tab"
|
||||||
|
:class="{ 'add-jobs__tab--active': addTab === 'url' }"
|
||||||
|
@click="addTab = 'url'"
|
||||||
|
>Paste URLs</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="addTab === 'csv'"
|
||||||
|
class="add-jobs__tab"
|
||||||
|
:class="{ 'add-jobs__tab--active': addTab === 'csv' }"
|
||||||
|
@click="addTab = 'csv'"
|
||||||
|
>Upload CSV</button>
|
||||||
|
</div>
|
||||||
|
<div class="add-jobs__panel" role="tabpanel">
|
||||||
|
<template v-if="addTab === 'url'">
|
||||||
|
<textarea
|
||||||
|
v-model="urlInput"
|
||||||
|
class="add-jobs__textarea"
|
||||||
|
placeholder="Paste one job URL per line…"
|
||||||
|
rows="4"
|
||||||
|
aria-label="Job URLs to add"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--primary"
|
||||||
|
:disabled="!urlInput.trim()"
|
||||||
|
@click="addByUrl"
|
||||||
|
>Add Jobs</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="home__section-desc">Upload a CSV with a <code>url</code> column.</p>
|
||||||
|
<input type="file" accept=".csv" aria-label="CSV file" @change="handleCsvUpload" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Advanced -->
|
||||||
|
<section class="home__section">
|
||||||
|
<details class="advanced">
|
||||||
|
<summary class="advanced__summary">Advanced</summary>
|
||||||
|
<div class="advanced__body">
|
||||||
|
<p class="advanced__warning">⚠️ These actions are destructive and cannot be undone.</p>
|
||||||
|
<div class="home__actions home__actions--danger">
|
||||||
|
<button class="action-btn action-btn--danger" @click="confirmPurge">
|
||||||
|
🗑️ Purge Pending + Rejected
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn--danger" @click="killTasks">
|
||||||
|
🛑 Kill Stuck Tasks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stoop speed toast — easter egg 9.2 -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
|
||||||
|
🦅 Stoop speed.
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useJobsStore } from '../stores/jobs'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import WorkflowButton from '../components/WorkflowButton.vue'
|
||||||
|
|
||||||
|
const store = useJobsStore()
|
||||||
|
|
||||||
|
// Greeting — easter egg 9.7: midnight mode
|
||||||
|
const userName = ref('')
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
const isMidnight = computed(() => hour >= 0 && hour < 5)
|
||||||
|
const greeting = computed(() => {
|
||||||
|
const name = userName.value ? `${userName.value}'s` : 'Your'
|
||||||
|
return isMidnight.value ? `${name} Late-Night Job Search` : `${name} Job Search`
|
||||||
|
})
|
||||||
|
|
||||||
|
const metrics = computed(() => [
|
||||||
|
{ status: 'pending', label: 'Pending', count: store.counts?.pending, link: '/review?status=pending' },
|
||||||
|
{ status: 'approve', label: 'Approved', count: store.counts?.approved, link: '/review?status=approved' },
|
||||||
|
{ status: 'applied', label: 'Applied', count: store.counts?.applied, link: '/review?status=applied' },
|
||||||
|
{ status: 'synced', label: 'Synced', count: store.counts?.synced, link: '/review?status=synced' },
|
||||||
|
{ status: 'reject', label: 'Rejected', count: store.counts?.rejected, link: '/review?status=rejected' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const integrationName = computed(() => store.status?.integration_name ?? 'Export')
|
||||||
|
const unsyncedCount = computed(() => store.status?.integration_unsynced ?? 0)
|
||||||
|
const showBacklog = computed(() => (store.counts?.pending ?? 0) > 0 || (store.counts?.approved ?? 0) > 0)
|
||||||
|
|
||||||
|
const enrichmentDotClass = computed(() =>
|
||||||
|
store.status?.enrichment_last_run ? 'enrichment-row__dot--ok' : 'enrichment-row__dot--idle',
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatRelative(isoStr: string) {
|
||||||
|
const mins = Math.round((Date.now() - new Date(isoStr).getTime()) / 60000)
|
||||||
|
if (mins < 2) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} min ago`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRunning = ref<string | null>(null)
|
||||||
|
const stoopToast = ref(false)
|
||||||
|
|
||||||
|
async function runTask(key: string, endpoint: string) {
|
||||||
|
taskRunning.value = key
|
||||||
|
await useApiFetch(endpoint, { method: 'POST' })
|
||||||
|
taskRunning.value = null
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
|
||||||
|
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
|
||||||
|
const scoreUnscored = () => runTask('score', '/api/tasks/score')
|
||||||
|
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
|
||||||
|
const runEnrich = () => useApiFetch('/api/tasks/enrich', { method: 'POST' })
|
||||||
|
|
||||||
|
const addTab = ref<'url' | 'csv'>('url')
|
||||||
|
const urlInput = ref('')
|
||||||
|
|
||||||
|
async function addByUrl() {
|
||||||
|
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
|
||||||
|
await useApiFetch('/api/jobs/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ urls }),
|
||||||
|
})
|
||||||
|
urlInput.value = ''
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCsvUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveByStatus(statuses: string[]) {
|
||||||
|
await useApiFetch('/api/jobs/archive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statuses }),
|
||||||
|
})
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPurge() {
|
||||||
|
// TODO: replace with ConfirmModal component
|
||||||
|
if (confirm('Permanently delete all pending and rejected jobs? This cannot be undone.')) {
|
||||||
|
useApiFetch('/api/jobs/purge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target: 'pending_rejected' }),
|
||||||
|
})
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killTasks() {
|
||||||
|
await useApiFetch('/api/tasks/kill', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
store.refresh()
|
||||||
|
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
|
||||||
|
if (data?.name) userName.value = data.name
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__header {
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__greeting {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
color: var(--app-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__subtitle {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-4) var(--space-3);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
min-height: 44px;
|
||||||
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
border-color: var(--app-primary-light);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__count {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--pending .metric-card__count { color: var(--status-pending); }
|
||||||
|
.metric-card--approve .metric-card__count { color: var(--status-approve); }
|
||||||
|
.metric-card--applied .metric-card__count { color: var(--status-applied); }
|
||||||
|
.metric-card--synced .metric-card__count { color: var(--status-synced); }
|
||||||
|
.metric-card--reject .metric-card__count { color: var(--status-reject); }
|
||||||
|
|
||||||
|
.home__section { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
|
||||||
|
.home__section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__section-desc { font-size: var(--text-sm); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.home__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||||
|
.home__actions--danger { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||||
|
|
||||||
|
.sync-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background: var(--app-primary-light);
|
||||||
|
border: 1px solid var(--app-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-banner:hover { background: var(--color-surface-alt); box-shadow: var(--shadow-sm); }
|
||||||
|
.sync-banner:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
min-height: 44px;
|
||||||
|
transition: background 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--primary { background: var(--app-accent); color: var(--app-accent-text); }
|
||||||
|
.action-btn--primary:hover { background: var(--app-accent-hover); }
|
||||||
|
.action-btn--primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.action-btn--secondary { background: var(--color-surface-alt); color: var(--color-text); border: 1px solid var(--color-border); }
|
||||||
|
.action-btn--secondary:hover { background: var(--color-border-light); }
|
||||||
|
|
||||||
|
.action-btn--danger { background: transparent; color: var(--color-error); border: 1px solid var(--color-error); }
|
||||||
|
.action-btn--danger:hover { background: rgba(192, 57, 43, 0.08); }
|
||||||
|
|
||||||
|
.enrichment-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enrichment-row__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.enrichment-row__dot--ok { background: var(--color-success); }
|
||||||
|
.enrichment-row__dot--idle { background: var(--color-text-muted); }
|
||||||
|
.enrichment-row__text { flex: 1; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost--sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
|
||||||
|
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
|
||||||
|
|
||||||
|
.add-jobs {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-jobs__tabs { display: flex; border-bottom: 1px solid var(--color-border-light); }
|
||||||
|
|
||||||
|
.add-jobs__tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms ease, border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-jobs__tab--active { color: var(--app-primary); border-bottom-color: var(--app-primary); font-weight: 600; }
|
||||||
|
|
||||||
|
.add-jobs__panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-jobs__textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.advanced {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced__summary {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced__summary::-webkit-details-marker { display: none; }
|
||||||
|
.advanced__summary::before { content: '▶ '; font-size: 0.7em; }
|
||||||
|
details[open] > .advanced__summary::before { content: '▼ '; }
|
||||||
|
|
||||||
|
.advanced__body { padding: 0 var(--space-4) var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
|
||||||
|
.advanced__warning {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-warning);
|
||||||
|
background: rgba(212, 137, 26, 0.08);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stoop-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-6);
|
||||||
|
right: var(--space-6);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: opacity 300ms ease, transform 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home { padding: var(--space-4); gap: var(--space-6); }
|
||||||
|
.home__greeting { font-size: var(--text-2xl); }
|
||||||
|
.home__metrics { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.home__metrics { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.home__metrics .metric-card:last-child { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
974
web/src/views/InterviewPrepView.vue
Normal file
974
web/src/views/InterviewPrepView.vue
Normal file
|
|
@ -0,0 +1,974 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { usePrepStore } from '../stores/prep'
|
||||||
|
import { useInterviewsStore } from '../stores/interviews'
|
||||||
|
import type { PipelineJob } from '../stores/interviews'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const prepStore = usePrepStore()
|
||||||
|
const interviewsStore = useInterviewsStore()
|
||||||
|
|
||||||
|
// ── Job ID ────────────────────────────────────────────────────────────────────
|
||||||
|
const jobId = computed<number | null>(() => {
|
||||||
|
const raw = route.params.id
|
||||||
|
if (!raw) return null
|
||||||
|
const n = Number(Array.isArray(raw) ? raw[0] : raw)
|
||||||
|
return isNaN(n) ? null : n
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Current job (from interviews store) ───────────────────────────────────────
|
||||||
|
const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
|
||||||
|
|
||||||
|
const job = ref<PipelineJob | null>(null)
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
type TabId = 'jd' | 'email' | 'letter'
|
||||||
|
const activeTab = ref<TabId>('jd')
|
||||||
|
|
||||||
|
// ── Call notes (localStorage via @vueuse/core) ────────────────────────────────
|
||||||
|
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
|
||||||
|
const callNotes = useStorage(notesKey, '')
|
||||||
|
|
||||||
|
// ── Page-level error (e.g. network failure during guard) ──────────────────────
|
||||||
|
const pageError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── Routing / guard ───────────────────────────────────────────────────────────
|
||||||
|
async function guardAndLoad() {
|
||||||
|
if (jobId.value === null) {
|
||||||
|
router.replace('/interviews')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the interviews store is populated
|
||||||
|
if (interviewsStore.jobs.length === 0) {
|
||||||
|
await interviewsStore.fetchAll()
|
||||||
|
if (interviewsStore.error) {
|
||||||
|
// Store fetch failed — don't redirect, show error
|
||||||
|
pageError.value = 'Failed to load job data. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = interviewsStore.jobs.find(j => j.id === jobId.value)
|
||||||
|
if (!found || !PREP_VALID_STATUSES.includes(found.status as typeof PREP_VALID_STATUSES[number])) {
|
||||||
|
router.replace('/interviews')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job.value = found
|
||||||
|
await prepStore.fetchFor(jobId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
guardAndLoad()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
prepStore.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Stage badge label ─────────────────────────────────────────────────────────
|
||||||
|
function stageBadgeLabel(status: string): string {
|
||||||
|
if (status === 'phone_screen') return 'Phone Screen'
|
||||||
|
if (status === 'interviewing') return 'Interviewing'
|
||||||
|
if (status === 'offer') return 'Offer'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interview date countdown ──────────────────────────────────────────────────
|
||||||
|
interface DateCountdown {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
cls: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const interviewCountdown = computed<DateCountdown | null>(() => {
|
||||||
|
const dateStr = job.value?.interview_date
|
||||||
|
if (!dateStr) return null
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const target = new Date(dateStr)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
const diffDays = Math.round((target.getTime() - today.getTime()) / 86400000)
|
||||||
|
|
||||||
|
if (diffDays === 0) return { icon: '🔴', label: 'TODAY', cls: 'countdown--today' }
|
||||||
|
if (diffDays === 1) return { icon: '🟡', label: 'TOMORROW', cls: 'countdown--tomorrow' }
|
||||||
|
if (diffDays > 1) return { icon: '🟢', label: `in ${diffDays} days`, cls: 'countdown--future' }
|
||||||
|
// Past
|
||||||
|
const ago = Math.abs(diffDays)
|
||||||
|
return { icon: '', label: `was ${ago} day${ago !== 1 ? 's' : ''} ago`, cls: 'countdown--past' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Research state helpers ────────────────────────────────────────────────────
|
||||||
|
const taskStatus = computed(() => prepStore.taskStatus)
|
||||||
|
const isRunning = computed(() => taskStatus.value.status === 'queued' || taskStatus.value.status === 'running')
|
||||||
|
const hasFailed = computed(() => taskStatus.value.status === 'failed')
|
||||||
|
const hasResearch = computed(() => !!prepStore.research)
|
||||||
|
|
||||||
|
// Stage label during generation
|
||||||
|
const stageLabel = computed(() => {
|
||||||
|
const s = taskStatus.value.stage
|
||||||
|
if (s) return s
|
||||||
|
return taskStatus.value.status === 'queued' ? 'Queued…' : 'Analyzing…'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generated-at caption
|
||||||
|
const generatedAtLabel = computed(() => {
|
||||||
|
const ts = prepStore.research?.generated_at
|
||||||
|
if (!ts) return null
|
||||||
|
const d = new Date(ts)
|
||||||
|
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Research sections ─────────────────────────────────────────────────────────
|
||||||
|
interface ResearchSection {
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
cls?: string
|
||||||
|
caption?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const researchSections = computed<ResearchSection[]>(() => {
|
||||||
|
const r = prepStore.research
|
||||||
|
if (!r) return []
|
||||||
|
|
||||||
|
const sections: ResearchSection[] = []
|
||||||
|
|
||||||
|
if (r.talking_points?.trim()) {
|
||||||
|
sections.push({ icon: '🎯', title: 'Talking Points', content: r.talking_points })
|
||||||
|
}
|
||||||
|
if (r.company_brief?.trim()) {
|
||||||
|
sections.push({ icon: '🏢', title: 'Company Overview', content: r.company_brief })
|
||||||
|
}
|
||||||
|
if (r.ceo_brief?.trim()) {
|
||||||
|
sections.push({ icon: '👤', title: 'Leadership & Culture', content: r.ceo_brief })
|
||||||
|
}
|
||||||
|
if (r.tech_brief?.trim()) {
|
||||||
|
sections.push({ icon: '⚙️', title: 'Tech Stack & Product', content: r.tech_brief })
|
||||||
|
}
|
||||||
|
if (r.funding_brief?.trim()) {
|
||||||
|
sections.push({ icon: '💰', title: 'Funding & Market Position', content: r.funding_brief })
|
||||||
|
}
|
||||||
|
if (r.red_flags?.trim() && !/no significant red flags/i.test(r.red_flags)) {
|
||||||
|
sections.push({ icon: '⚠️', title: 'Red Flags & Watch-outs', content: r.red_flags, cls: 'section--warning' })
|
||||||
|
}
|
||||||
|
if (r.accessibility_brief?.trim()) {
|
||||||
|
sections.push({
|
||||||
|
icon: '♿',
|
||||||
|
title: 'Inclusion & Accessibility',
|
||||||
|
content: r.accessibility_brief,
|
||||||
|
caption: 'For your personal evaluation — not disclosed in any application.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Match score badge ─────────────────────────────────────────────────────────
|
||||||
|
const matchScore = computed(() => prepStore.fullJob?.match_score ?? null)
|
||||||
|
|
||||||
|
function matchScoreBadge(score: number | null): { icon: string; cls: string } {
|
||||||
|
if (score === null) return { icon: '—', cls: 'score--none' }
|
||||||
|
if (score >= 70) return { icon: `🟢 ${score}%`, cls: 'score--high' }
|
||||||
|
if (score >= 40) return { icon: `🟡 ${score}%`, cls: 'score--mid' }
|
||||||
|
return { icon: `🔴 ${score}%`, cls: 'score--low' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyword gaps ──────────────────────────────────────────────────────────────
|
||||||
|
const keywordGaps = computed<string[]>(() => {
|
||||||
|
const raw = prepStore.fullJob?.keyword_gaps
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) return parsed.map(String)
|
||||||
|
} catch {
|
||||||
|
// Fall through: return raw as single item
|
||||||
|
}
|
||||||
|
return [raw]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Generate / refresh ────────────────────────────────────────────────────────
|
||||||
|
async function onGenerate() {
|
||||||
|
if (jobId.value === null) return
|
||||||
|
await prepStore.generateResearch(jobId.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prep-view">
|
||||||
|
<!-- Loading skeleton while interviews store loads -->
|
||||||
|
<div v-if="interviewsStore.loading && !job" class="prep-loading" aria-live="polite">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="job">
|
||||||
|
<div class="prep-layout">
|
||||||
|
<!-- ══════════════ LEFT COLUMN ══════════════ -->
|
||||||
|
<aside class="prep-left" aria-label="Job overview and research">
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<RouterLink to="/interviews" class="back-link">← Back to Interviews</RouterLink>
|
||||||
|
|
||||||
|
<!-- Job header -->
|
||||||
|
<header class="job-header">
|
||||||
|
<h1 class="job-title">{{ job.title }}</h1>
|
||||||
|
<p class="job-company">{{ job.company }}</p>
|
||||||
|
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="stage-badge" :class="`stage-badge--${job.status}`">
|
||||||
|
{{ stageBadgeLabel(job.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="interviewCountdown"
|
||||||
|
class="countdown-chip"
|
||||||
|
:class="interviewCountdown.cls"
|
||||||
|
>
|
||||||
|
<span v-if="interviewCountdown.icon" aria-hidden="true">{{ interviewCountdown.icon }}</span>
|
||||||
|
{{ interviewCountdown.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="job.url"
|
||||||
|
:href="job.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-link-out"
|
||||||
|
>
|
||||||
|
Open job listing ↗
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Research controls -->
|
||||||
|
<section class="research-controls" aria-label="Research controls">
|
||||||
|
<!-- No research and no active task → show generate button -->
|
||||||
|
<template v-if="!hasResearch && !isRunning && !hasFailed">
|
||||||
|
<button class="btn-primary" @click="onGenerate" :disabled="prepStore.loading">
|
||||||
|
Generate research brief
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Task running/queued → spinner + stage -->
|
||||||
|
<template v-else-if="isRunning">
|
||||||
|
<div class="research-running" aria-live="polite" aria-atomic="true">
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
<span>{{ stageLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Task failed → error + retry -->
|
||||||
|
<template v-else-if="hasFailed">
|
||||||
|
<div class="research-error" role="alert">
|
||||||
|
<span>⚠️ {{ taskStatus.message ?? 'Research generation failed.' }}</span>
|
||||||
|
<button class="btn-secondary" @click="onGenerate">Retry</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Research exists (completed or no task but research present) → show refresh -->
|
||||||
|
<template v-else-if="hasResearch">
|
||||||
|
<div class="research-generated">
|
||||||
|
<span v-if="generatedAtLabel" class="research-ts">Generated: {{ generatedAtLabel }}</span>
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="onGenerate"
|
||||||
|
:disabled="isRunning"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Error banner (store-level) -->
|
||||||
|
<div v-if="prepStore.error" class="error-banner" role="alert">
|
||||||
|
{{ prepStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Research sections -->
|
||||||
|
<div v-if="hasResearch" class="research-sections">
|
||||||
|
<section
|
||||||
|
v-for="sec in researchSections"
|
||||||
|
:key="sec.title"
|
||||||
|
class="research-section"
|
||||||
|
:class="sec.cls"
|
||||||
|
>
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
|
||||||
|
</h2>
|
||||||
|
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p>
|
||||||
|
<div class="section-body">{{ sec.content }}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state: no research yet and not loading -->
|
||||||
|
<div v-else-if="!isRunning && !prepStore.loading" class="research-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<p>Generate a research brief to see company info, talking points, and more.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ══════════════ RIGHT COLUMN ══════════════ -->
|
||||||
|
<main class="prep-right" aria-label="Job details">
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar" role="tablist" aria-label="Job details tabs">
|
||||||
|
<button
|
||||||
|
id="tab-jd"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ 'tab-btn--active': activeTab === 'jd' }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'jd'"
|
||||||
|
aria-controls="tabpanel-jd"
|
||||||
|
@click="activeTab = 'jd'"
|
||||||
|
>
|
||||||
|
Job Description
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="tab-email"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ 'tab-btn--active': activeTab === 'email' }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'email'"
|
||||||
|
aria-controls="tabpanel-email"
|
||||||
|
@click="activeTab = 'email'"
|
||||||
|
>
|
||||||
|
Email History
|
||||||
|
<span v-if="prepStore.contacts.length" class="tab-count">{{ prepStore.contacts.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="tab-letter"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ 'tab-btn--active': activeTab === 'letter' }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'letter'"
|
||||||
|
aria-controls="tabpanel-letter"
|
||||||
|
@click="activeTab = 'letter'"
|
||||||
|
>
|
||||||
|
Cover Letter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── JD tab ── -->
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'jd'"
|
||||||
|
id="tabpanel-jd"
|
||||||
|
class="tab-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-jd"
|
||||||
|
>
|
||||||
|
<div class="jd-meta">
|
||||||
|
<span
|
||||||
|
class="score-badge"
|
||||||
|
:class="matchScoreBadge(matchScore).cls"
|
||||||
|
:aria-label="`Match score: ${matchScore ?? 'unknown'}%`"
|
||||||
|
>
|
||||||
|
{{ matchScoreBadge(matchScore).icon }}
|
||||||
|
</span>
|
||||||
|
<div v-if="keywordGaps.length" class="keyword-gaps">
|
||||||
|
<span class="keyword-gaps-label">Keyword gaps:</span>
|
||||||
|
<span class="keyword-gaps-list">{{ keywordGaps.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="prepStore.fullJob?.description" class="jd-body">
|
||||||
|
{{ prepStore.fullJob.description }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="tab-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<p>No job description available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Email tab ── -->
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'email'"
|
||||||
|
id="tabpanel-email"
|
||||||
|
class="tab-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-email"
|
||||||
|
>
|
||||||
|
<div v-if="prepStore.contactsError" class="error-state" role="alert">
|
||||||
|
{{ prepStore.contactsError }}
|
||||||
|
</div>
|
||||||
|
<template v-else-if="prepStore.contacts.length">
|
||||||
|
<div
|
||||||
|
v-for="contact in prepStore.contacts"
|
||||||
|
:key="contact.id"
|
||||||
|
class="email-card"
|
||||||
|
>
|
||||||
|
<div class="email-header">
|
||||||
|
<span class="email-dir" :title="contact.direction === 'inbound' ? 'Inbound' : 'Outbound'">
|
||||||
|
{{ contact.direction === 'inbound' ? '📥' : '📤' }}
|
||||||
|
</span>
|
||||||
|
<span class="email-subject">{{ contact.subject ?? '(no subject)' }}</span>
|
||||||
|
<span class="email-date" v-if="contact.received_at">
|
||||||
|
{{ new Date(contact.received_at).toLocaleDateString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="email-from" v-if="contact.from_addr">{{ contact.from_addr }}</div>
|
||||||
|
<div class="email-body" v-if="contact.body">{{ contact.body.slice(0, 500) }}{{ contact.body.length > 500 ? '…' : '' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="tab-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<p>No email history for this job.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Cover letter tab ── -->
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'letter'"
|
||||||
|
id="tabpanel-letter"
|
||||||
|
class="tab-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-letter"
|
||||||
|
>
|
||||||
|
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
|
||||||
|
{{ prepStore.fullJob.cover_letter }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="tab-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<p>No cover letter generated yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Call notes ── -->
|
||||||
|
<section class="call-notes" aria-label="Call notes">
|
||||||
|
<h2 class="call-notes-title">Call Notes</h2>
|
||||||
|
<textarea
|
||||||
|
v-model="callNotes"
|
||||||
|
class="call-notes-textarea"
|
||||||
|
placeholder="Jot down notes during your call…"
|
||||||
|
aria-label="Call notes — saved locally"
|
||||||
|
></textarea>
|
||||||
|
<p class="call-notes-caption">Notes are saved locally — they won't sync between devices.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Network/load error — don't redirect, show message -->
|
||||||
|
<div v-else-if="pageError" class="error-banner" role="alert">
|
||||||
|
{{ pageError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback while redirecting -->
|
||||||
|
<div v-else class="prep-loading" aria-live="polite">
|
||||||
|
Redirecting…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||||
|
.prep-view {
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-12);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40% 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: single column */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.prep-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.prep-right {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.prep-left {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-left {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--nav-height, 4rem) + var(--space-4));
|
||||||
|
max-height: calc(100vh - var(--nav-height, 4rem) - var(--space-8));
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
/* On mobile, don't stick */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.prep-left {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ─────────────────────────────────────────────────────────────── */
|
||||||
|
.prep-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-16);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Back link ──────────────────────────────────────────────────────────── */
|
||||||
|
.back-link {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Job header ─────────────────────────────────────────────────────────── */
|
||||||
|
.job-header {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-company {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage badges */
|
||||||
|
.stage-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.stage-badge--phone_screen {
|
||||||
|
background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised));
|
||||||
|
color: var(--status-phone);
|
||||||
|
}
|
||||||
|
.stage-badge--interviewing {
|
||||||
|
background: color-mix(in srgb, var(--status-interview) 12%, var(--color-surface-raised));
|
||||||
|
color: var(--status-interview);
|
||||||
|
}
|
||||||
|
.stage-badge--offer {
|
||||||
|
background: color-mix(in srgb, var(--status-offer) 12%, var(--color-surface-raised));
|
||||||
|
color: var(--status-offer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown chip */
|
||||||
|
.countdown-chip {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.countdown--today { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
|
||||||
|
.countdown--tomorrow { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
|
||||||
|
.countdown--future { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
|
||||||
|
.countdown--past { background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.btn-link-out {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.btn-link-out:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Research controls ──────────────────────────────────────────────────── */
|
||||||
|
.research-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--app-primary-hover); }
|
||||||
|
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: var(--color-surface-alt); }
|
||||||
|
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
.research-running {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--color-info) 25%, transparent);
|
||||||
|
border-top-color: var(--color-info);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 700ms linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.spinner { animation: none; border-top-color: var(--color-info); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-generated {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.research-ts {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||||
|
.error-banner {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
|
||||||
|
color: var(--color-error);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline error state for tab panels (e.g. contacts fetch failure) */
|
||||||
|
.error-state {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
|
||||||
|
color: var(--color-error);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Research sections ───────────────────────────────────────────────────── */
|
||||||
|
.research-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-section {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-section.section--warning {
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
|
||||||
|
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-caption {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.research-empty,
|
||||||
|
.tab-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-bird {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.tab-empty p {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 2px solid var(--color-border-light);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition), border-color var(--transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: -2px;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--app-primary); }
|
||||||
|
.tab-btn--active {
|
||||||
|
color: var(--app-primary);
|
||||||
|
border-bottom-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab panels ──────────────────────────────────────────────────────────── */
|
||||||
|
.tab-panel {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JD tab */
|
||||||
|
.jd-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.score--high { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
|
||||||
|
.score--mid { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
|
||||||
|
.score--low { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
|
||||||
|
.score--none { background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.keyword-gaps {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.keyword-gaps-label { font-weight: 700; }
|
||||||
|
|
||||||
|
.jd-body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Email tab */
|
||||||
|
.email-card {
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.email-card:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.email-dir { font-size: 1rem; }
|
||||||
|
.email-subject {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.email-date {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.email-from {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover letter tab */
|
||||||
|
.letter-body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Call notes ──────────────────────────────────────────────────────────── */
|
||||||
|
.call-notes {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-notes-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-notes-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.call-notes-textarea::placeholder { color: var(--color-text-muted); }
|
||||||
|
.call-notes-textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-notes-caption {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
736
web/src/views/InterviewsView.vue
Normal file
736
web/src/views/InterviewsView.vue
Normal file
|
|
@ -0,0 +1,736 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useInterviewsStore } from '../stores/interviews'
|
||||||
|
import type { PipelineJob, PipelineStage } from '../stores/interviews'
|
||||||
|
import type { StageSignal } from '../stores/interviews'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import InterviewCard from '../components/InterviewCard.vue'
|
||||||
|
import MoveToSheet from '../components/MoveToSheet.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useInterviewsStore()
|
||||||
|
|
||||||
|
// ── Move sheet ────────────────────────────────────────────────────────────────
|
||||||
|
const moveTarget = ref<PipelineJob | null>(null)
|
||||||
|
const movePreSelected = ref<PipelineStage | undefined>(undefined)
|
||||||
|
|
||||||
|
function openMove(jobId: number, preSelectedStage?: PipelineStage) {
|
||||||
|
moveTarget.value = store.jobs.find(j => j.id === jobId) ?? null
|
||||||
|
movePreSelected.value = preSelectedStage
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
|
||||||
|
if (!moveTarget.value) return
|
||||||
|
const wasHired = stage === 'hired'
|
||||||
|
await store.move(moveTarget.value.id, stage, opts)
|
||||||
|
moveTarget.value = null
|
||||||
|
if (wasHired) triggerConfetti()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collapsible Applied section ────────────────────────────────────────────
|
||||||
|
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
|
||||||
|
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
|
||||||
|
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))
|
||||||
|
|
||||||
|
const APPLIED_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const appliedPage = ref(0)
|
||||||
|
const allApplied = computed(() => [...store.applied, ...store.survey])
|
||||||
|
const appliedPageCount = computed(() => Math.ceil(allApplied.value.length / APPLIED_PAGE_SIZE))
|
||||||
|
const pagedApplied = computed(() =>
|
||||||
|
allApplied.value.slice(
|
||||||
|
appliedPage.value * APPLIED_PAGE_SIZE,
|
||||||
|
(appliedPage.value + 1) * APPLIED_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clamp page when the list shrinks (e.g. after a move)
|
||||||
|
watch(allApplied, () => {
|
||||||
|
if (appliedPage.value >= appliedPageCount.value) appliedPage.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const appliedSignalCount = computed(() =>
|
||||||
|
[...store.applied, ...store.survey]
|
||||||
|
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Signal metadata (pre-list rows) ───────────────────────────────────────
|
||||||
|
const SIGNAL_META_PRE = {
|
||||||
|
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
|
||||||
|
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
|
||||||
|
offer_received: { label: 'Move to Offer', stage: 'offer' as PipelineStage, color: 'green' },
|
||||||
|
survey_received: { label: 'Move to Survey', stage: 'survey' as PipelineStage, color: 'amber' },
|
||||||
|
rejected: { label: 'Mark Rejected', stage: 'interview_rejected' as PipelineStage, color: 'red' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const sigExpandedIds = ref(new Set<number>())
|
||||||
|
// IMPORTANT: must reassign .value (not mutate in place) to trigger Vue reactivity
|
||||||
|
function togglePreSigExpand(jobId: number) {
|
||||||
|
const next = new Set(sigExpandedIds.value)
|
||||||
|
if (next.has(jobId)) next.delete(jobId)
|
||||||
|
else next.add(jobId)
|
||||||
|
sigExpandedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissPreSignal(job: PipelineJob, sig: StageSignal) {
|
||||||
|
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
|
||||||
|
if (idx !== -1) job.stage_signals.splice(idx, 1)
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyExpandedMap = ref<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
function toggleBodyExpand(sigId: number) {
|
||||||
|
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRE_RECLASSIFY_CHIPS = [
|
||||||
|
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
|
||||||
|
{ label: '✅ Positive', value: 'positive_response' as const },
|
||||||
|
{ label: '🟢 Offer', value: 'offer_received' as const },
|
||||||
|
{ label: '📋 Survey', value: 'survey_received' as const },
|
||||||
|
{ label: '✖ Rejected', value: 'rejected' as const },
|
||||||
|
{ label: '🚫 Unrelated', value: 'unrelated' },
|
||||||
|
{ label: '📰 Digest', value: 'digest' },
|
||||||
|
{ label: '— Neutral', value: 'neutral' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
|
||||||
|
|
||||||
|
async function reclassifyPreSignal(job: PipelineJob, sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
|
||||||
|
if (DISMISS_LABELS.has(newLabel)) {
|
||||||
|
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
|
||||||
|
if (idx !== -1) job.stage_signals.splice(idx, 1)
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
|
})
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
|
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
|
||||||
|
if (newLabel === 'digest') {
|
||||||
|
void useApiFetch('/api/digest-queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ job_contact_id: sig.id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prev = sig.stage_signal
|
||||||
|
sig.stage_signal = newLabel
|
||||||
|
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
|
})
|
||||||
|
if (error) sig.stage_signal = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email sync status ──────────────────────────────────────────────────────
|
||||||
|
interface SyncStatus {
|
||||||
|
state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
|
||||||
|
lastCompletedAt: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncStatus = ref<SyncStatus>({ state: 'idle', lastCompletedAt: null, error: null })
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let syncPollId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let nowTickId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function elapsedLabel(isoTs: string | null): string {
|
||||||
|
if (!isoTs) return ''
|
||||||
|
const diffMs = now.value - new Date(isoTs).getTime()
|
||||||
|
const mins = Math.floor(diffMs / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSyncStatus() {
|
||||||
|
const { data } = await useApiFetch<{
|
||||||
|
status: string; last_completed_at: string | null; error: string | null
|
||||||
|
}>('/api/email/sync/status')
|
||||||
|
if (!data) return
|
||||||
|
syncStatus.value = {
|
||||||
|
state: data.status as SyncStatus['state'],
|
||||||
|
lastCompletedAt: data.last_completed_at,
|
||||||
|
error: data.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSyncPoll() {
|
||||||
|
if (syncPollId) return
|
||||||
|
syncPollId = setInterval(async () => {
|
||||||
|
await fetchSyncStatus()
|
||||||
|
if (syncStatus.value.state === 'completed' || syncStatus.value.state === 'failed') {
|
||||||
|
clearInterval(syncPollId!); syncPollId = null
|
||||||
|
if (syncStatus.value.state === 'completed') store.fetchAll()
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerSync() {
|
||||||
|
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') return
|
||||||
|
const { data, error } = await useApiFetch<{ task_id: number }>('/api/email/sync', { method: 'POST' })
|
||||||
|
if (error) {
|
||||||
|
if (error.kind === 'http' && error.status === 503) {
|
||||||
|
// Email integration not configured — set permanently for this session
|
||||||
|
syncStatus.value = { state: 'not_configured', lastCompletedAt: null, error: null }
|
||||||
|
} else {
|
||||||
|
// Transient error (network, server 5xx etc.) — show failed but allow retry
|
||||||
|
syncStatus.value = { ...syncStatus.value, state: 'failed', error: error.kind === 'http' ? error.detail : error.message }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
syncStatus.value = { ...syncStatus.value, state: 'queued' }
|
||||||
|
startSyncPoll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confetti (easter egg 9.5) ─────────────────────────────────────────────────
|
||||||
|
const showHiredToast = ref(false)
|
||||||
|
const confettiCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
function triggerConfetti() {
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reducedMotion) {
|
||||||
|
showHiredToast.value = true
|
||||||
|
setTimeout(() => { showHiredToast.value = false }, 6000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const canvas = confettiCanvas.value
|
||||||
|
if (!canvas) return
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
canvas.style.display = 'block'
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const COLORS = ['#c4732a','#1a7a6e','#3b82f6','#f5c518','#e84393','#6ab870']
|
||||||
|
const particles = Array.from({ length: 120 }, (_, i) => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: -10 - Math.random() * 200,
|
||||||
|
r: 4 + Math.random() * 6,
|
||||||
|
color: COLORS[i % COLORS.length],
|
||||||
|
vx: (Math.random() - 0.5) * 4,
|
||||||
|
vy: 3 + Math.random() * 4,
|
||||||
|
angle: Math.random() * 360,
|
||||||
|
spin: (Math.random() - 0.5) * 8,
|
||||||
|
}))
|
||||||
|
let frame = 0
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas!.width, canvas!.height)
|
||||||
|
particles.forEach(p => {
|
||||||
|
p.x += p.vx; p.y += p.vy; p.vy += 0.08; p.angle += p.spin
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(p.x, p.y)
|
||||||
|
ctx.rotate((p.angle * Math.PI) / 180)
|
||||||
|
ctx.fillStyle = p.color
|
||||||
|
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6)
|
||||||
|
ctx.restore()
|
||||||
|
})
|
||||||
|
frame++
|
||||||
|
if (frame < 240) requestAnimationFrame(draw)
|
||||||
|
else canvas!.style.display = 'none'
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard navigation ───────────────────────────────────────────────────────
|
||||||
|
const focusedCol = ref(0)
|
||||||
|
const focusedCard = ref(0)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ jobs: () => store.phoneScreen },
|
||||||
|
{ jobs: () => store.interviewing },
|
||||||
|
{ jobs: () => store.offerHired },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (moveTarget.value) return
|
||||||
|
const colJobs = columns[focusedCol.value].jobs()
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
|
||||||
|
e.preventDefault(); focusedCard.value = Math.max(0, focusedCard.value - 1)
|
||||||
|
} else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
||||||
|
e.preventDefault(); focusedCard.value = Math.min(colJobs.length - 1, focusedCard.value + 1)
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === '[' || e.key === '4') {
|
||||||
|
e.preventDefault(); focusedCol.value = Math.max(0, focusedCol.value - 1); focusedCard.value = 0
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === ']' || e.key === '6') {
|
||||||
|
e.preventDefault(); focusedCol.value = Math.min(columns.length - 1, focusedCol.value + 1); focusedCard.value = 0
|
||||||
|
} else if (e.key === 'm' || e.key === 'M') {
|
||||||
|
const job = colJobs[focusedCard.value]; if (job) openMove(job.id)
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
const job = colJobs[focusedCard.value]; if (job) router.push(`/prep/${job.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchAll()
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
await fetchSyncStatus()
|
||||||
|
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') {
|
||||||
|
startSyncPoll()
|
||||||
|
}
|
||||||
|
nowTickId = setInterval(() => { now.value = Date.now() }, 60000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
if (syncPollId) { clearInterval(syncPollId); syncPollId = null }
|
||||||
|
if (nowTickId) { clearInterval(nowTickId); nowTickId = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
function daysSince(dateStr: string | null) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="interviews-view">
|
||||||
|
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
|
||||||
|
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="showHiredToast" class="hired-toast" role="alert">
|
||||||
|
🎉 Congratulations! You got the job!
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<header class="view-header">
|
||||||
|
<h1 class="view-title">Interviews</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<!-- Email sync pill -->
|
||||||
|
<button
|
||||||
|
v-if="syncStatus.state === 'not_configured'"
|
||||||
|
class="sync-pill sync-pill--muted"
|
||||||
|
disabled
|
||||||
|
aria-label="Email not configured"
|
||||||
|
>📧 Email not configured</button>
|
||||||
|
<button
|
||||||
|
v-else-if="syncStatus.state === 'queued' || syncStatus.state === 'running'"
|
||||||
|
class="sync-pill sync-pill--syncing"
|
||||||
|
disabled
|
||||||
|
aria-label="Syncing emails"
|
||||||
|
>⏳ Syncing…</button>
|
||||||
|
<button
|
||||||
|
v-else-if="(syncStatus.state === 'completed' || syncStatus.state === 'idle') && syncStatus.lastCompletedAt"
|
||||||
|
class="sync-pill sync-pill--synced"
|
||||||
|
@click="triggerSync"
|
||||||
|
:aria-label="`Email synced ${elapsedLabel(syncStatus.lastCompletedAt)} — click to re-sync`"
|
||||||
|
>📧 Synced {{ elapsedLabel(syncStatus.lastCompletedAt) }}</button>
|
||||||
|
<button
|
||||||
|
v-else-if="syncStatus.state === 'failed'"
|
||||||
|
class="sync-pill sync-pill--failed"
|
||||||
|
@click="triggerSync"
|
||||||
|
aria-label="Sync failed — click to retry"
|
||||||
|
>⚠ Sync failed</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="sync-pill sync-pill--idle"
|
||||||
|
@click="triggerSync"
|
||||||
|
aria-label="Sync emails"
|
||||||
|
>📧 Sync Emails</button>
|
||||||
|
|
||||||
|
<button class="btn-refresh" @click="store.fetchAll()" :disabled="store.loading" aria-label="Refresh">
|
||||||
|
{{ store.loading ? '⟳' : '↺' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
|
||||||
|
|
||||||
|
<!-- Pre-list: Applied + Survey (collapsible) -->
|
||||||
|
<section class="pre-list" aria-label="Applied jobs">
|
||||||
|
<button
|
||||||
|
class="pre-list-toggle"
|
||||||
|
@click="appliedExpanded = !appliedExpanded"
|
||||||
|
:aria-expanded="appliedExpanded"
|
||||||
|
aria-controls="pre-list-body"
|
||||||
|
>
|
||||||
|
<span class="pre-list-chevron" :class="{ 'is-expanded': appliedExpanded }">▶</span>
|
||||||
|
<span class="pre-list-toggle-title">
|
||||||
|
Applied
|
||||||
|
<span class="pre-list-count">{{ store.applied.length + store.survey.length }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="appliedSignalCount > 0" class="pre-list-signal-count">⚡ {{ appliedSignalCount }} signal{{ appliedSignalCount !== 1 ? 's' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="pre-list-body"
|
||||||
|
class="pre-list-body"
|
||||||
|
:class="{ 'is-expanded': appliedExpanded }"
|
||||||
|
>
|
||||||
|
<div v-if="store.applied.length === 0 && store.survey.length === 0" class="pre-list-empty">
|
||||||
|
<span class="empty-bird">🦅</span>
|
||||||
|
<span>No applied jobs yet. <RouterLink to="/apply">Go to Apply</RouterLink> to submit applications.</span>
|
||||||
|
</div>
|
||||||
|
<template v-for="job in pagedApplied" :key="job.id">
|
||||||
|
<div class="pre-list-row">
|
||||||
|
<div class="pre-row-info">
|
||||||
|
<span class="pre-row-title">{{ job.title }}</span>
|
||||||
|
<span class="pre-row-company">{{ job.company }}</span>
|
||||||
|
<span v-if="job.status === 'survey'" class="survey-badge">Survey</span>
|
||||||
|
</div>
|
||||||
|
<div class="pre-row-meta">
|
||||||
|
<span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
|
||||||
|
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… ›</button>
|
||||||
|
<button
|
||||||
|
v-if="job.status === 'survey'"
|
||||||
|
class="btn-move-pre"
|
||||||
|
@click="router.push('/survey/' + job.id)"
|
||||||
|
>Survey →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Signal banners for pre-list rows -->
|
||||||
|
<template v-if="job.stage_signals?.length">
|
||||||
|
<div
|
||||||
|
v-for="sig in (job.stage_signals ?? []).slice(0, sigExpandedIds.has(job.id) ? undefined : 1)"
|
||||||
|
:key="sig.id"
|
||||||
|
class="pre-signal-banner"
|
||||||
|
:data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
|
||||||
|
>
|
||||||
|
<div class="signal-header">
|
||||||
|
<span class="signal-label">📧 <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label?.replace('Move to ', '') ?? sig.stage_signal }}</strong></span>
|
||||||
|
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
|
||||||
|
<div class="signal-header-actions">
|
||||||
|
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)"
|
||||||
|
:aria-expanded="bodyExpandedMap[sig.id] ?? false"
|
||||||
|
:aria-label="(bodyExpandedMap[sig.id] ? 'Hide' : 'Read') + ' email body'">
|
||||||
|
{{ bodyExpandedMap[sig.id] ? '▾ Hide' : '▸ Read' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-signal-move"
|
||||||
|
@click.stop="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
|
||||||
|
:aria-label="`Move ${job.title} — ${SIGNAL_META_PRE[sig.stage_signal]?.label ?? 'Move'}`"
|
||||||
|
>→ Move</button>
|
||||||
|
<button class="btn-signal-dismiss" @click.stop="dismissPreSignal(job, sig)" aria-label="Dismiss signal">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded body + reclassify chips -->
|
||||||
|
<div v-if="bodyExpandedMap[sig.id]" class="signal-body-expanded">
|
||||||
|
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
|
||||||
|
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
|
||||||
|
<div v-else class="signal-body-empty">No email body available.</div>
|
||||||
|
<div class="signal-reclassify">
|
||||||
|
<span class="signal-reclassify-label">Re-classify:</span>
|
||||||
|
<button
|
||||||
|
v-for="chip in PRE_RECLASSIFY_CHIPS"
|
||||||
|
:key="chip.value"
|
||||||
|
class="btn-chip"
|
||||||
|
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
|
||||||
|
@click.stop="reclassifyPreSignal(job, sig, chip.value)"
|
||||||
|
>{{ chip.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="(job.stage_signals?.length ?? 0) > 1"
|
||||||
|
class="btn-sig-expand"
|
||||||
|
@click="togglePreSigExpand(job.id)"
|
||||||
|
>{{ sigExpandedIds.has(job.id) ? '− less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="appliedPageCount > 1" class="pre-list-pagination">
|
||||||
|
<button
|
||||||
|
class="btn-page"
|
||||||
|
:disabled="appliedPage === 0"
|
||||||
|
@click="appliedPage--"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>‹</button>
|
||||||
|
<span class="page-indicator">{{ appliedPage + 1 }} / {{ appliedPageCount }}</span>
|
||||||
|
<button
|
||||||
|
class="btn-page"
|
||||||
|
:disabled="appliedPage >= appliedPageCount - 1"
|
||||||
|
@click="appliedPage++"
|
||||||
|
aria-label="Next page"
|
||||||
|
>›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Kanban columns -->
|
||||||
|
<section class="kanban" aria-label="Interview pipeline">
|
||||||
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 0 }" aria-label="Phone Screen">
|
||||||
|
<div class="col-header" style="color: var(--status-phone)">
|
||||||
|
📞 Phone Screen <span class="col-count">{{ store.phoneScreen.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.phoneScreen.length === 0" class="col-empty">
|
||||||
|
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
|
||||||
|
<p class="empty-msg">No phone screens yet.<br>Move an applied job here when a recruiter reaches out.</p>
|
||||||
|
</div>
|
||||||
|
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
|
||||||
|
:focused="focusedCol === 0 && focusedCard === i"
|
||||||
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
|
||||||
|
<div class="col-header" style="color: var(--color-info)">
|
||||||
|
🎯 Interviewing <span class="col-count">{{ store.interviewing.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.interviewing.length === 0" class="col-empty">
|
||||||
|
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
|
||||||
|
<p class="empty-msg">Phone screen going well?<br>Move it here when you've got a real interview scheduled.</p>
|
||||||
|
</div>
|
||||||
|
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
|
||||||
|
:focused="focusedCol === 1 && focusedCard === i"
|
||||||
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
|
||||||
|
<div class="col-header" style="color: var(--status-offer)">
|
||||||
|
📜 Offer / Hired <span class="col-count">{{ store.offerHired.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.offerHired.length === 0" class="col-empty">
|
||||||
|
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
|
||||||
|
<p class="empty-msg">This is where offers land.<br>You've got this. 🙌</p>
|
||||||
|
</div>
|
||||||
|
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
|
||||||
|
:focused="focusedCol === 2 && focusedCard === i"
|
||||||
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Rejected accordion -->
|
||||||
|
<details class="rejected-accordion" v-if="store.rejected.length > 0">
|
||||||
|
<summary class="rejected-summary">
|
||||||
|
✗ Rejected ({{ store.rejected.length }})
|
||||||
|
<span class="rejected-hint">— expand for details</span>
|
||||||
|
</summary>
|
||||||
|
<div class="rejected-body">
|
||||||
|
<div class="rejected-stats">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-num">{{ store.rejected.length }}</span>
|
||||||
|
<span class="stat-lbl">Total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
|
||||||
|
<span class="rejected-title">{{ job.title }} — {{ job.company }}</span>
|
||||||
|
<span class="rejected-stage">{{ job.rejection_stage ?? 'No response' }}</span>
|
||||||
|
<button class="btn-unrej" @click="openMove(job.id)">Move →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<MoveToSheet
|
||||||
|
v-if="moveTarget"
|
||||||
|
:currentStatus="moveTarget.status"
|
||||||
|
:jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
|
||||||
|
:preSelectedStage="movePreSelected"
|
||||||
|
@move="onMove"
|
||||||
|
@close="moveTarget = null; movePreSelected = undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.interviews-view {
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-12);
|
||||||
|
max-width: 1100px; margin: 0 auto; position: relative;
|
||||||
|
}
|
||||||
|
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
|
||||||
|
.hired-toast {
|
||||||
|
position: fixed; bottom: var(--space-8); left: 50%; transform: translateX(-50%);
|
||||||
|
background: var(--color-success); color: #fff;
|
||||||
|
padding: var(--space-3) var(--space-6); border-radius: 12px;
|
||||||
|
font-weight: 700; font-size: 1.1rem; z-index: 400;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: all 400ms ease; }
|
||||||
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||||
|
.view-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
|
||||||
|
.view-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||||
|
.btn-refresh { background: none; border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer; padding: 4px 10px; font-size: 1rem; color: var(--color-text-muted); }
|
||||||
|
.error-banner { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); padding: var(--space-2) var(--space-3); border-radius: 8px; margin-bottom: var(--space-4); }
|
||||||
|
|
||||||
|
/* Header actions */
|
||||||
|
.header-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
|
||||||
|
|
||||||
|
/* Email sync pill */
|
||||||
|
.sync-pill {
|
||||||
|
border-radius: 999px; padding: 3px 10px; font-size: 0.78em; font-weight: 600; cursor: pointer;
|
||||||
|
border: 1px solid transparent; transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
.sync-pill:disabled { cursor: default; opacity: 0.8; }
|
||||||
|
.sync-pill--idle { border-color: var(--color-border); background: none; color: var(--color-text-muted); }
|
||||||
|
.sync-pill--syncing { background: color-mix(in srgb, var(--color-info) 10%, var(--color-surface)); color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 30%, transparent); animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
.sync-pill--synced { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface)); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 30%, transparent); }
|
||||||
|
.sync-pill--failed { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.sync-pill--muted { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border-light); }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
|
||||||
|
|
||||||
|
/* Collapsible pre-list toggle header */
|
||||||
|
.pre-list-toggle {
|
||||||
|
display: flex; align-items: center; gap: var(--space-2); width: 100%;
|
||||||
|
background: none; border: none; cursor: pointer; padding: var(--space-1) 0;
|
||||||
|
font-size: 0.9rem; font-weight: 700; color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pre-list-chevron { font-size: 0.7em; color: var(--color-text-muted); transition: transform 200ms; display: inline-block; }
|
||||||
|
.pre-list-chevron.is-expanded { transform: rotate(90deg); }
|
||||||
|
.pre-list-count {
|
||||||
|
display: inline-block; background: var(--color-surface-raised); border-radius: 999px;
|
||||||
|
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
|
||||||
|
|
||||||
|
/* Collapsible pre-list body */
|
||||||
|
.pre-list-body {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease;
|
||||||
|
}
|
||||||
|
.pre-list-body.is-expanded { max-height: 800px; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pre-list-body, .pre-list-chevron { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre-list { background: var(--color-surface); border-radius: 10px; padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6); }
|
||||||
|
.pre-list-toggle-title { display: flex; align-items: center; }
|
||||||
|
.pre-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); padding: var(--space-2) 0; }
|
||||||
|
.pre-list-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-top: 1px solid var(--color-border-light); gap: var(--space-3); }
|
||||||
|
.pre-row-info { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
|
||||||
|
.pre-row-title { font-weight: 600; font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.pre-row-company { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
.survey-badge { background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised)); color: var(--status-phone); border-radius: 99px; padding: 1px 7px; font-size: 0.7rem; font-weight: 700; }
|
||||||
|
.pre-row-meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
|
||||||
|
.pre-row-days { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
.btn-move-pre { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
|
||||||
|
|
||||||
|
/* Pre-list signal banners */
|
||||||
|
.pre-signal-banner {
|
||||||
|
padding: 8px 12px; border-radius: 6px; margin: 4px 0;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
|
||||||
|
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
|
||||||
|
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
|
||||||
|
|
||||||
|
.signal-label { font-size: 0.82em; }
|
||||||
|
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||||
|
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.btn-signal-move {
|
||||||
|
background: var(--color-primary); color: #fff;
|
||||||
|
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-signal-dismiss {
|
||||||
|
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.btn-signal-read {
|
||||||
|
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
|
||||||
|
cursor: pointer; padding: 2px 6px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.signal-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.signal-header-actions {
|
||||||
|
margin-left: auto; display: flex; gap: 6px; align-items: center;
|
||||||
|
}
|
||||||
|
.signal-body-expanded {
|
||||||
|
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.signal-from {
|
||||||
|
color: var(--color-text-muted); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.signal-body-text {
|
||||||
|
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.signal-body-empty {
|
||||||
|
color: var(--color-text-muted); font-style: italic;
|
||||||
|
}
|
||||||
|
.signal-reclassify {
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.signal-reclassify-label {
|
||||||
|
font-size: 0.75em; color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.btn-chip {
|
||||||
|
background: var(--color-surface); color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border); border-radius: 4px;
|
||||||
|
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-chip:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
}
|
||||||
|
.btn-chip-active {
|
||||||
|
background: var(--color-primary-muted, #e8f0ff);
|
||||||
|
color: var(--color-primary); border-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-sig-expand {
|
||||||
|
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
|
||||||
|
padding: 4px 12px; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4); margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) { .kanban { grid-template-columns: 1fr; } }
|
||||||
|
.kanban-col {
|
||||||
|
background: var(--color-surface); border-radius: 10px;
|
||||||
|
padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3);
|
||||||
|
transition: box-shadow 150ms;
|
||||||
|
}
|
||||||
|
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
|
||||||
|
.col-header {
|
||||||
|
font-size: 0.8rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .05em; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.col-count { background: rgba(0,0,0,.08); border-radius: 99px; padding: 1px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-text-muted); }
|
||||||
|
.col-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: var(--space-2); padding: var(--space-6) var(--space-3); text-align: center;
|
||||||
|
}
|
||||||
|
.empty-bird-wrap { background: var(--color-surface-alt); border-radius: 50%; width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.empty-bird-float { font-size: 1.75rem; animation: float 3s ease-in-out infinite; }
|
||||||
|
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
|
||||||
|
.empty-msg { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.5; }
|
||||||
|
.rejected-accordion { border: 1px solid var(--color-border-light); border-radius: 10px; overflow: hidden; }
|
||||||
|
.rejected-summary {
|
||||||
|
list-style: none; padding: var(--space-3) var(--space-4);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
|
||||||
|
cursor: pointer; font-weight: 700; font-size: 0.85rem; color: var(--color-error);
|
||||||
|
display: flex; align-items: center; gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.rejected-summary::-webkit-details-marker { display: none; }
|
||||||
|
.rejected-hint { font-weight: 400; color: var(--color-text-muted); font-size: 0.75rem; }
|
||||||
|
.rejected-body { padding: var(--space-3) var(--space-4); background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised)); display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.rejected-stats { display: flex; gap: var(--space-3); margin-bottom: var(--space-2); }
|
||||||
|
.stat-chip { background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border-light); text-align: center; }
|
||||||
|
.stat-num { display: block; font-size: 1.25rem; font-weight: 700; color: var(--color-error); }
|
||||||
|
.stat-lbl { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||||
|
.rejected-row { display: flex; align-items: center; gap: var(--space-3); background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border-left: 3px solid var(--color-error); }
|
||||||
|
.rejected-title { flex: 1; font-weight: 600; font-size: 0.875rem; }
|
||||||
|
.rejected-stage { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
.btn-unrej { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
|
||||||
|
.empty-bird { font-size: 1.25rem; }
|
||||||
|
.pre-list-pagination {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: var(--space-2);
|
||||||
|
padding: 6px 12px; border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.btn-page {
|
||||||
|
background: none; border: 1px solid var(--color-border); border-radius: 4px;
|
||||||
|
color: var(--color-text); font-size: 0.9em; padding: 2px 10px; cursor: pointer;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.btn-page:disabled {
|
||||||
|
opacity: 0.35; cursor: default;
|
||||||
|
}
|
||||||
|
.btn-page:not(:disabled):hover {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.page-indicator {
|
||||||
|
font-size: 0.8em; color: var(--color-text-muted); min-width: 40px; text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
834
web/src/views/JobReviewView.vue
Normal file
834
web/src/views/JobReviewView.vue
Normal file
|
|
@ -0,0 +1,834 @@
|
||||||
|
<template>
|
||||||
|
<div class="review">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="review__header">
|
||||||
|
<div class="review__title-row">
|
||||||
|
<h1 class="review__title">Review Jobs</h1>
|
||||||
|
<button class="help-btn" :aria-expanded="showHelp" @click="showHelp = !showHelp">
|
||||||
|
<span aria-hidden="true">?</span>
|
||||||
|
<span class="sr-only">Keyboard shortcuts</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter tabs (segmented control) -->
|
||||||
|
<div class="review__tabs" role="tablist" aria-label="Filter by status">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.status"
|
||||||
|
role="tab"
|
||||||
|
class="review__tab"
|
||||||
|
:class="{ 'review__tab--active': activeTab === tab.status }"
|
||||||
|
:aria-selected="activeTab === tab.status"
|
||||||
|
@click="setTab(tab.status)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span v-if="tab.status === 'pending' && store.remaining > 0" class="tab-badge">
|
||||||
|
{{ store.remaining }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── PENDING: card stack ──────────────────────────────────────────── -->
|
||||||
|
<div v-if="activeTab === 'pending'" class="review__body">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading" class="review__loading" aria-live="polite" aria-label="Loading jobs…">
|
||||||
|
<span class="spinner" aria-hidden="true" />
|
||||||
|
<span>Loading queue…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state — falcon stoop animation (easter egg 9.3) -->
|
||||||
|
<div v-else-if="store.remaining === 0 && !store.loading" class="review__empty" role="status">
|
||||||
|
<span class="empty-falcon" aria-hidden="true">🦅</span>
|
||||||
|
<h2 class="empty-title">Queue cleared.</h2>
|
||||||
|
<p class="empty-desc">Nothing to review right now. Run discovery to find new listings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card stack -->
|
||||||
|
<template v-else-if="store.currentJob">
|
||||||
|
<!-- Keyboard hint bar -->
|
||||||
|
<div class="hint-bar" aria-hidden="true">
|
||||||
|
<span class="hint"><kbd>←</kbd><kbd>J</kbd> Reject</span>
|
||||||
|
<span class="hint-counter">{{ store.remaining }} remaining</span>
|
||||||
|
<span class="hint"><kbd>→</kbd><kbd>L</kbd> Approve</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JobCardStack
|
||||||
|
ref="stackRef"
|
||||||
|
:job="store.currentJob"
|
||||||
|
:remaining="store.remaining"
|
||||||
|
@approve="onApprove"
|
||||||
|
@reject="onReject"
|
||||||
|
@skip="onSkip"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Action buttons (non-swipe path) -->
|
||||||
|
<div class="review__actions" aria-label="Review actions">
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--reject"
|
||||||
|
aria-label="Reject this job"
|
||||||
|
@click="stackRef?.dismissReject()"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✗</span> Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--skip"
|
||||||
|
aria-label="Skip — come back later"
|
||||||
|
@click="stackRef?.dismissSkip()"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">→↓</span> Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--approve"
|
||||||
|
aria-label="Approve this job"
|
||||||
|
@click="stackRef?.dismissApprove()"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✓</span> Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Undo hint -->
|
||||||
|
<p class="review__undo-hint" aria-hidden="true">Press <kbd>Z</kbd> to undo</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── OTHER STATUS: list view ──────────────────────────────────────── -->
|
||||||
|
<div v-else class="review__body">
|
||||||
|
<div v-if="store.loading" class="review__loading" aria-live="polite">
|
||||||
|
<span class="spinner" aria-hidden="true" />
|
||||||
|
<span>Loading…</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.listJobs.length === 0" class="review__empty" role="status">
|
||||||
|
<p class="empty-desc">No {{ activeTab }} jobs.</p>
|
||||||
|
</div>
|
||||||
|
<ul v-else class="job-list" role="list">
|
||||||
|
<li v-for="job in store.listJobs" :key="job.id" class="job-list__item">
|
||||||
|
<div class="job-list__info">
|
||||||
|
<span class="job-list__title">{{ job.title }}</span>
|
||||||
|
<span class="job-list__company">{{ job.company }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-list__meta">
|
||||||
|
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
|
||||||
|
{{ job.match_score }}%
|
||||||
|
</span>
|
||||||
|
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
|
||||||
|
View ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Help overlay ─────────────────────────────────────────────────── -->
|
||||||
|
<Transition name="overlay">
|
||||||
|
<div
|
||||||
|
v-if="showHelp"
|
||||||
|
class="help-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="help-title"
|
||||||
|
@click.self="showHelp = false"
|
||||||
|
>
|
||||||
|
<div class="help-modal">
|
||||||
|
<h2 id="help-title" class="help-modal__title">Keyboard Shortcuts</h2>
|
||||||
|
<dl class="help-keys">
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>→</kbd> / <kbd>L</kbd></dt>
|
||||||
|
<dd>Approve</dd>
|
||||||
|
</div>
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>←</kbd> / <kbd>J</kbd></dt>
|
||||||
|
<dd>Reject</dd>
|
||||||
|
</div>
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>S</kbd></dt>
|
||||||
|
<dd>Skip (come back later)</dd>
|
||||||
|
</div>
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>Enter</kbd></dt>
|
||||||
|
<dd>Expand / collapse description</dd>
|
||||||
|
</div>
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>Z</kbd></dt>
|
||||||
|
<dd>Undo last action</dd>
|
||||||
|
</div>
|
||||||
|
<div class="help-keys__row">
|
||||||
|
<dt><kbd>?</kbd></dt>
|
||||||
|
<dd>Toggle this help</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<button class="help-modal__close" @click="showHelp = false" aria-label="Close help">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- ── Undo toast ────────────────────────────────────────────────────── -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div
|
||||||
|
v-if="undoToast"
|
||||||
|
class="undo-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span>{{ undoToast.message }}</span>
|
||||||
|
<button class="undo-toast__btn" @click="doUndo">Undo</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- ── Stoop speed toast — easter egg 9.2 ───────────────────────────── -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="stoopToastVisible" class="stoop-toast" role="status" aria-live="polite">
|
||||||
|
🦅 Stoop speed.
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useReviewStore } from '../stores/review'
|
||||||
|
import JobCardStack from '../components/JobCardStack.vue'
|
||||||
|
|
||||||
|
const store = useReviewStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
|
||||||
|
|
||||||
|
// ─── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ status: 'pending', label: 'Pending' },
|
||||||
|
{ status: 'approved', label: 'Approved' },
|
||||||
|
{ status: 'rejected', label: 'Rejected' },
|
||||||
|
{ status: 'applied', label: 'Applied' },
|
||||||
|
{ status: 'synced', label: 'Synced' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.status as string) ?? 'pending')
|
||||||
|
|
||||||
|
async function setTab(status: string) {
|
||||||
|
activeTab.value = status
|
||||||
|
if (status === 'pending') {
|
||||||
|
await store.fetchQueue()
|
||||||
|
} else {
|
||||||
|
await store.fetchList(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Undo toast ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const undoToast = ref<{ message: string } | null>(null)
|
||||||
|
let toastTimer = 0
|
||||||
|
|
||||||
|
function showUndoToast(action: 'approved' | 'rejected' | 'skipped') {
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
undoToast.value = { message: `${capitalize(action)}` }
|
||||||
|
toastTimer = window.setTimeout(() => { undoToast.value = null }, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUndo() {
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
undoToast.value = null
|
||||||
|
await store.undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
|
||||||
|
|
||||||
|
// ─── Action handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function onApprove() {
|
||||||
|
const job = store.currentJob
|
||||||
|
if (!job) return
|
||||||
|
await store.approve(job)
|
||||||
|
showUndoToast('approved')
|
||||||
|
checkStoopSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReject() {
|
||||||
|
const job = store.currentJob
|
||||||
|
if (!job) return
|
||||||
|
await store.reject(job)
|
||||||
|
showUndoToast('rejected')
|
||||||
|
checkStoopSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSkip() {
|
||||||
|
const job = store.currentJob
|
||||||
|
if (!job) return
|
||||||
|
store.skip(job)
|
||||||
|
showUndoToast('skipped')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stoop speed — easter egg 9.2 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stoopToastVisible = ref(false)
|
||||||
|
|
||||||
|
function checkStoopSpeed() {
|
||||||
|
if (!store.stoopAchieved && store.isStoopSpeed) {
|
||||||
|
store.markStoopAchieved()
|
||||||
|
stoopToastVisible.value = true
|
||||||
|
setTimeout(() => { stoopToastVisible.value = false }, 3500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard shortcuts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const showHelp = ref(false)
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
// Don't steal keys when typing in an input
|
||||||
|
if ((e.target as Element).closest('input, textarea, select, [contenteditable]')) return
|
||||||
|
if (activeTab.value !== 'pending') return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'l':
|
||||||
|
case 'L':
|
||||||
|
e.preventDefault()
|
||||||
|
stackRef.value?.dismissApprove()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'j':
|
||||||
|
case 'J':
|
||||||
|
e.preventDefault()
|
||||||
|
stackRef.value?.dismissReject()
|
||||||
|
break
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
e.preventDefault()
|
||||||
|
stackRef.value?.dismissSkip()
|
||||||
|
break
|
||||||
|
case 'z':
|
||||||
|
case 'Z':
|
||||||
|
e.preventDefault()
|
||||||
|
doUndo()
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
// Expand/collapse — bubble to the card's button naturally; no action needed here
|
||||||
|
break
|
||||||
|
case '?':
|
||||||
|
showHelp.value = !showHelp.value
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
showHelp.value = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List view score pill ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scorePillClass(score: number) {
|
||||||
|
if (score >= 80) return 'score-pill--high'
|
||||||
|
if (score >= 60) return 'score-pill--mid'
|
||||||
|
return 'score-pill--low'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
await store.fetchQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.review__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
color: var(--app-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-btn:hover { background: var(--app-primary-light); border-color: var(--app-primary); }
|
||||||
|
|
||||||
|
/* ── Tabs ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.review__tabs {
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.review__tab {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--radius-lg) - 3px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
transition: background 150ms ease, color 150ms ease;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__tab--active {
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: white;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__tab:not(.review__tab--active):hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
background: var(--color-warning);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review__tab--active .tab-badge { background: rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
/* ── Body ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.review__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.review__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-12);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state — falcon stoop (easter egg 9.3) ────────────────────── */
|
||||||
|
|
||||||
|
.review__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-16) var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-falcon {
|
||||||
|
font-size: 4rem;
|
||||||
|
animation: falcon-stoop 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes falcon-stoop {
|
||||||
|
0% { transform: translateY(-60px) rotate(-30deg); opacity: 0; }
|
||||||
|
60% { transform: translateY(6px) rotate(0deg); opacity: 1; }
|
||||||
|
80% { transform: translateY(-4px); }
|
||||||
|
100% { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.empty-falcon { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 32ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hint bar ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hint-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-counter {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action buttons ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.review__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
min-height: 44px;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
.action-btn--reject {
|
||||||
|
background: rgba(192, 57, 43, 0.08);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.action-btn--reject:hover { background: rgba(192, 57, 43, 0.16); }
|
||||||
|
|
||||||
|
.action-btn--skip {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.action-btn--skip:hover { background: var(--color-surface-alt); }
|
||||||
|
|
||||||
|
.action-btn--approve {
|
||||||
|
background: rgba(39, 174, 96, 0.08);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.action-btn--approve:hover { background: rgba(39, 174, 96, 0.16); }
|
||||||
|
|
||||||
|
.review__undo-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Job list (non-pending tabs) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.job-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: 44px;
|
||||||
|
transition: box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__item:hover { box-shadow: var(--shadow-sm); }
|
||||||
|
|
||||||
|
.job-list__info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.job-list__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__company {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.score-pill {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-pill--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
|
||||||
|
.score-pill--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
|
||||||
|
.score-pill--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
|
||||||
|
|
||||||
|
.job-list__link {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Help overlay ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.help-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 400;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-6);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--shadow-xl, 0 16px 48px rgba(0,0,0,0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-keys { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
|
||||||
|
.help-keys__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-keys__row dt { width: 6rem; flex-shrink: 0; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.help-keys__row dd { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.help-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal__close:hover { background: var(--color-surface-alt); }
|
||||||
|
|
||||||
|
/* ── Toasts ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.undo-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-6);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 300;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast__btn {
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast__btn:hover { background: var(--app-primary-hover); }
|
||||||
|
|
||||||
|
.stoop-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--space-6) + 56px);
|
||||||
|
right: var(--space-6);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast transitions ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: opacity 280ms ease, transform 280ms ease; }
|
||||||
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(8px) translateX(-50%); }
|
||||||
|
.stoop-toast.toast-enter-from,
|
||||||
|
.stoop-toast.toast-leave-to { transform: translateY(8px); }
|
||||||
|
|
||||||
|
.overlay-enter-active, .overlay-leave-active { transition: opacity 200ms ease; }
|
||||||
|
.overlay-enter-from, .overlay-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Screen reader only ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.review { padding: var(--space-4); gap: var(--space-4); }
|
||||||
|
.review__title { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
.review__tab {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-bar { display: none; } /* mobile: no room — swipe speaks for itself */
|
||||||
|
|
||||||
|
.review__actions { gap: var(--space-2); }
|
||||||
|
.action-btn { padding: var(--space-3) var(--space-4); font-size: var(--text-xs); }
|
||||||
|
|
||||||
|
.undo-toast {
|
||||||
|
left: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
transform: none;
|
||||||
|
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-4));
|
||||||
|
}
|
||||||
|
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
834
web/src/views/SurveyView.vue
Normal file
834
web/src/views/SurveyView.vue
Normal file
|
|
@ -0,0 +1,834 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useInterviewsStore } from '../stores/interviews'
|
||||||
|
import { useSurveyStore } from '../stores/survey'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const interviewsStore = useInterviewsStore()
|
||||||
|
const surveyStore = useSurveyStore()
|
||||||
|
|
||||||
|
const VALID_STAGES = ['survey', 'phone_screen', 'interviewing', 'offer']
|
||||||
|
|
||||||
|
const rawId = route.params.id
|
||||||
|
const jobId = rawId ? parseInt(String(rawId), 10) : NaN
|
||||||
|
const pickerMode = !rawId || isNaN(jobId)
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
let saveSuccessTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const activeTab = ref<'text' | 'screenshot'>('text')
|
||||||
|
const textInput = ref('')
|
||||||
|
const imageB64 = ref<string | null>(null)
|
||||||
|
const imagePreviewUrl = ref<string | null>(null)
|
||||||
|
const selectedMode = ref<'quick' | 'detailed'>('quick')
|
||||||
|
const surveyName = ref('')
|
||||||
|
const reportedScore = ref('')
|
||||||
|
const saveSuccess = ref(false)
|
||||||
|
|
||||||
|
// Computed job from store
|
||||||
|
const job = computed(() =>
|
||||||
|
interviewsStore.jobs.find(j => j.id === jobId) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jobs eligible for survey (used in picker mode)
|
||||||
|
const pickerJobs = computed(() =>
|
||||||
|
interviewsStore.jobs.filter(j => VALID_STAGES.includes(j.status))
|
||||||
|
)
|
||||||
|
|
||||||
|
const stageLabel: Record<string, string> = {
|
||||||
|
survey: 'Survey', phone_screen: 'Phone Screen',
|
||||||
|
interviewing: 'Interviewing', offer: 'Offer',
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (interviewsStore.jobs.length === 0) {
|
||||||
|
await interviewsStore.fetchAll()
|
||||||
|
}
|
||||||
|
if (pickerMode) return
|
||||||
|
if (!job.value || !VALID_STAGES.includes(job.value.status)) {
|
||||||
|
router.replace('/interviews')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await surveyStore.fetchFor(jobId)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
surveyStore.clear()
|
||||||
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Screenshot handling
|
||||||
|
function handlePaste(e: ClipboardEvent) {
|
||||||
|
if (!surveyStore.visionAvailable) return
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) loadImageFile(file)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!surveyStore.visionAvailable) return
|
||||||
|
const file = e.dataTransfer?.files[0]
|
||||||
|
if (file && file.type.startsWith('image/')) loadImageFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (file) loadImageFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageFile(file: File) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const result = ev.target?.result as string
|
||||||
|
imagePreviewUrl.value = result
|
||||||
|
imageB64.value = result.split(',')[1] // strip "data:image/...;base64,"
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
imageB64.value = null
|
||||||
|
imagePreviewUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis
|
||||||
|
const canAnalyze = computed(() =>
|
||||||
|
activeTab.value === 'text' ? textInput.value.trim().length > 0 : imageB64.value !== null
|
||||||
|
)
|
||||||
|
|
||||||
|
async function runAnalyze() {
|
||||||
|
const payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' } = {
|
||||||
|
mode: selectedMode.value,
|
||||||
|
}
|
||||||
|
if (activeTab.value === 'screenshot' && imageB64.value) {
|
||||||
|
payload.image_b64 = imageB64.value
|
||||||
|
} else {
|
||||||
|
payload.text = textInput.value
|
||||||
|
}
|
||||||
|
await surveyStore.analyze(jobId, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
async function saveToJob() {
|
||||||
|
await surveyStore.saveResponse(jobId, {
|
||||||
|
surveyName: surveyName.value,
|
||||||
|
reportedScore: reportedScore.value,
|
||||||
|
image_b64: activeTab.value === 'screenshot' ? imageB64.value ?? undefined : undefined,
|
||||||
|
})
|
||||||
|
if (!surveyStore.error) {
|
||||||
|
saveSuccess.value = true
|
||||||
|
surveyName.value = ''
|
||||||
|
reportedScore.value = ''
|
||||||
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
|
||||||
|
saveSuccessTimer = setTimeout(() => { saveSuccess.value = false }, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History accordion
|
||||||
|
const historyOpen = ref(false)
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
const expandedHistory = ref<Set<number>>(new Set())
|
||||||
|
function toggleHistoryEntry(id: number) {
|
||||||
|
const next = new Set(expandedHistory.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
expandedHistory.value = next
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="survey-layout">
|
||||||
|
|
||||||
|
<!-- ── Job picker (no id in route) ── -->
|
||||||
|
<div v-if="pickerMode" class="survey-content picker-mode">
|
||||||
|
<h2 class="picker-heading">Survey Assistant</h2>
|
||||||
|
<p class="picker-sub">Select a job to open the survey assistant.</p>
|
||||||
|
<div v-if="pickerJobs.length === 0" class="picker-empty">
|
||||||
|
No jobs in an active interview stage. Move a job to Survey, Phone Screen, Interviewing, or Offer first.
|
||||||
|
</div>
|
||||||
|
<ul v-else class="picker-list" role="list">
|
||||||
|
<li
|
||||||
|
v-for="j in pickerJobs"
|
||||||
|
:key="j.id"
|
||||||
|
class="picker-item"
|
||||||
|
@click="router.push('/survey/' + j.id)"
|
||||||
|
>
|
||||||
|
<div class="picker-item__main">
|
||||||
|
<span class="picker-item__company">{{ j.company }}</span>
|
||||||
|
<span class="picker-item__title">{{ j.title }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="stage-badge">{{ stageLabel[j.status] ?? j.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Survey assistant (id present) ── -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Sticky context bar -->
|
||||||
|
<div class="context-bar" v-if="job">
|
||||||
|
<span class="context-company">{{ job.company }}</span>
|
||||||
|
<span class="context-sep">·</span>
|
||||||
|
<span class="context-title">{{ job.title }}</span>
|
||||||
|
<span class="stage-badge">{{ stageLabel[job.status] ?? job.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load/history error banner -->
|
||||||
|
<div class="error-banner" v-if="surveyStore.error && !surveyStore.analysis">
|
||||||
|
{{ surveyStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="survey-content">
|
||||||
|
<!-- Input card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'text' }"
|
||||||
|
@click="activeTab = 'text'"
|
||||||
|
>📝 Paste Text</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'screenshot', disabled: !surveyStore.visionAvailable }"
|
||||||
|
:aria-disabled="!surveyStore.visionAvailable"
|
||||||
|
:title="!surveyStore.visionAvailable ? 'Vision service not running — start it with: bash scripts/manage-vision.sh start' : undefined"
|
||||||
|
@click="surveyStore.visionAvailable && (activeTab = 'screenshot')"
|
||||||
|
>📷 Screenshot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text tab -->
|
||||||
|
<div v-if="activeTab === 'text'" class="tab-panel">
|
||||||
|
<textarea
|
||||||
|
v-model="textInput"
|
||||||
|
class="survey-textarea"
|
||||||
|
placeholder="Paste your survey questions here, e.g.: Q1: Which best describes your work style? A. I prefer working alone B. I thrive in teams C. Depends on the project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot tab -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="screenshot-zone"
|
||||||
|
role="region"
|
||||||
|
aria-label="Screenshot upload area — paste, drag, or choose file"
|
||||||
|
@paste="handlePaste"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="handleDrop"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div v-if="imagePreviewUrl" class="image-preview">
|
||||||
|
<img :src="imagePreviewUrl" alt="Survey screenshot preview" />
|
||||||
|
<button class="remove-btn" @click="clearImage">✕ Remove</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="drop-hint">
|
||||||
|
<p>Paste (Ctrl+V), drag & drop, or upload a screenshot</p>
|
||||||
|
<label class="upload-label">
|
||||||
|
Choose file
|
||||||
|
<input type="file" accept="image/*" class="file-input" @change="handleFileUpload" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode selection -->
|
||||||
|
<div class="mode-cards">
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
:class="{ selected: selectedMode === 'quick' }"
|
||||||
|
@click="selectedMode = 'quick'"
|
||||||
|
>
|
||||||
|
<span class="mode-icon">⚡</span>
|
||||||
|
<span class="mode-name">Quick</span>
|
||||||
|
<span class="mode-desc">Best answer + one-liner per question</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
:class="{ selected: selectedMode === 'detailed' }"
|
||||||
|
@click="selectedMode = 'detailed'"
|
||||||
|
>
|
||||||
|
<span class="mode-icon">📋</span>
|
||||||
|
<span class="mode-name">Detailed</span>
|
||||||
|
<span class="mode-desc">Option-by-option breakdown with reasoning</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analyze button -->
|
||||||
|
<button
|
||||||
|
class="analyze-btn"
|
||||||
|
:disabled="!canAnalyze || surveyStore.loading"
|
||||||
|
@click="runAnalyze"
|
||||||
|
>
|
||||||
|
<span v-if="surveyStore.loading" class="spinner" aria-hidden="true"></span>
|
||||||
|
{{ surveyStore.loading ? 'Analyzing…' : '🔍 Analyze' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Analyze error -->
|
||||||
|
<div class="error-inline" v-if="surveyStore.error && !surveyStore.analysis">
|
||||||
|
{{ surveyStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results card -->
|
||||||
|
<div class="card results-card" v-if="surveyStore.analysis">
|
||||||
|
<div class="results-output">{{ surveyStore.analysis.output }}</div>
|
||||||
|
<div class="save-form">
|
||||||
|
<input
|
||||||
|
v-model="surveyName"
|
||||||
|
class="save-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Survey name (e.g. Culture Fit Round 1)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="reportedScore"
|
||||||
|
class="save-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Reported score (e.g. 82% or 4.2/5)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="surveyStore.saving"
|
||||||
|
@click="saveToJob"
|
||||||
|
>
|
||||||
|
<span v-if="surveyStore.saving" class="spinner" aria-hidden="true"></span>
|
||||||
|
💾 Save to job
|
||||||
|
</button>
|
||||||
|
<div v-if="saveSuccess" class="save-success">Saved!</div>
|
||||||
|
<div v-if="surveyStore.error" class="error-inline">{{ surveyStore.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History accordion -->
|
||||||
|
<details class="history-accordion" :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open">
|
||||||
|
<summary class="history-summary">
|
||||||
|
Survey history ({{ surveyStore.history.length }} response{{ surveyStore.history.length === 1 ? '' : 's' }})
|
||||||
|
</summary>
|
||||||
|
<div v-if="surveyStore.history.length === 0" class="history-empty">No responses saved yet.</div>
|
||||||
|
<div v-else class="history-list">
|
||||||
|
<div v-for="resp in surveyStore.history" :key="resp.id" class="history-entry">
|
||||||
|
<button class="history-toggle" @click="toggleHistoryEntry(resp.id)">
|
||||||
|
<span class="history-name">{{ resp.survey_name ?? 'Survey response' }}</span>
|
||||||
|
<span class="history-meta">{{ formatDate(resp.received_at) }}{{ resp.reported_score ? ` · ${resp.reported_score}` : '' }}</span>
|
||||||
|
<span class="history-chevron">{{ expandedHistory.has(resp.id) ? '▲' : '▼' }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedHistory.has(resp.id)" class="history-detail">
|
||||||
|
<div class="history-tags">
|
||||||
|
<span class="tag">{{ resp.mode }}</span>
|
||||||
|
<span class="tag">{{ resp.source }}</span>
|
||||||
|
<span v-if="resp.received_at" class="tag">{{ resp.received_at }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-output">{{ resp.llm_output }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template><!-- end v-else (id present) -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.survey-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
height: 40px;
|
||||||
|
background: var(--color-surface-raised, #f8f9fa);
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-company {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-sep {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-title {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-content {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-zone {
|
||||||
|
min-height: 160px;
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed var(--color-border, #e2e8f0);
|
||||||
|
margin: var(--space-4);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-zone:focus {
|
||||||
|
border-color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: 2px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card.selected {
|
||||||
|
border-color: var(--color-accent, #3182ce);
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--color-accent, #3182ce);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-surface-raised, #f8f9fa);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-success {
|
||||||
|
color: var(--color-success, #38a169);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-accordion {
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-weight: 500;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.history-empty {
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-chevron {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-detail {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
color: var(--color-accent, #3182ce);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: var(--color-error-subtle, #fff5f5);
|
||||||
|
border-bottom: 1px solid var(--color-error, #fc8181);
|
||||||
|
padding: var(--space-2) var(--space-6);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error-text, #c53030);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-inline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error-text, #c53030);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn .spinner {
|
||||||
|
border-color: rgba(255,255,255,0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn .spinner {
|
||||||
|
border-color: rgba(0,0,0,0.15);
|
||||||
|
border-top-color: var(--color-accent, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Picker mode ── */
|
||||||
|
.picker-mode {
|
||||||
|
padding-top: var(--space-8, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sub {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-empty {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px dashed var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item:hover {
|
||||||
|
border-color: var(--color-accent, #3182ce);
|
||||||
|
background: var(--color-accent-subtle, #ebf4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item__company {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item__title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #718096);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
web/src/views/settings/DataView.vue
Normal file
81
web/src/views/settings/DataView.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useDataStore } from '../../stores/settings/data'
|
||||||
|
|
||||||
|
const store = useDataStore()
|
||||||
|
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
|
||||||
|
const includeDb = ref(false)
|
||||||
|
const showRestoreConfirm = ref(false)
|
||||||
|
const restoreFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
function formatBytes(b: number) {
|
||||||
|
if (b < 1024) return `${b} B`
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||||
|
return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-view">
|
||||||
|
<h2>Data & Backup</h2>
|
||||||
|
|
||||||
|
<!-- Backup -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Create Backup</h3>
|
||||||
|
<p class="section-note">Exports your config files (and optionally the job database) as a zip archive.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="includeDb" /> Include job database (staging.db)
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.createBackup(includeDb)" :disabled="creatingBackup" class="btn-primary">
|
||||||
|
{{ creatingBackup ? 'Creating…' : 'Create Backup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="backupError" class="error-msg">{{ backupError }}</p>
|
||||||
|
<div v-if="backupPath" class="backup-result">
|
||||||
|
<span>{{ backupFileCount }} files · {{ formatBytes(backupSizeBytes) }}</span>
|
||||||
|
<span class="backup-path">{{ backupPath }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Restore -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Restore from Backup</h3>
|
||||||
|
<p class="section-note">Upload a backup zip to restore your configuration. Existing files will be overwritten.</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
@change="restoreFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
@click="showRestoreConfirm = true"
|
||||||
|
:disabled="!restoreFile || store.restoring"
|
||||||
|
class="btn-warning"
|
||||||
|
>
|
||||||
|
{{ store.restoring ? 'Restoring…' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.restoreResult" class="restore-result">
|
||||||
|
<p>Restored {{ store.restoreResult.restored.length }} files.</p>
|
||||||
|
<p v-if="store.restoreResult.skipped.length">Skipped: {{ store.restoreResult.skipped.join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.restoreError" class="error-msg">{{ store.restoreError }}</p>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRestoreConfirm" class="modal-overlay" @click.self="showRestoreConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Restore Backup?</h3>
|
||||||
|
<p>This will overwrite your current configuration. This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-danger">Restore</button>
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
130
web/src/views/settings/DeveloperView.vue
Normal file
130
web/src/views/settings/DeveloperView.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const devTierOverride = ref<string | null>(null)
|
||||||
|
const hfTokenInput = ref('')
|
||||||
|
const hfTokenSet = ref(false)
|
||||||
|
const hfTestResult = ref<{ok: boolean; error?: string; username?: string} | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showWizardResetConfirm = ref(false)
|
||||||
|
const exportResult = ref<{count: number} | null>(null)
|
||||||
|
|
||||||
|
const TIERS = ['free', 'paid', 'premium', 'ultra']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await useApiFetch<{dev_tier_override: string | null; hf_token_set: boolean}>('/api/settings/developer')
|
||||||
|
if (data) {
|
||||||
|
devTierOverride.value = data.dev_tier_override ?? null
|
||||||
|
hfTokenSet.value = data.hf_token_set
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveTierOverride() {
|
||||||
|
saving.value = true
|
||||||
|
await useApiFetch('/api/settings/developer/tier', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tier: devTierOverride.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
// Reload page so tier gate updates
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHfToken() {
|
||||||
|
if (!hfTokenInput.value) return
|
||||||
|
await useApiFetch('/api/settings/developer/hf-token', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: hfTokenInput.value }),
|
||||||
|
})
|
||||||
|
hfTokenSet.value = true
|
||||||
|
hfTokenInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHfToken() {
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; error?: string; username?: string}>('/api/settings/developer/hf-token/test', { method: 'POST' })
|
||||||
|
hfTestResult.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetWizard() {
|
||||||
|
await useApiFetch('/api/settings/developer/wizard-reset', { method: 'POST' })
|
||||||
|
showWizardResetConfirm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportClassifier() {
|
||||||
|
const { data } = await useApiFetch<{count: number}>('/api/settings/developer/export-classifier', { method: 'POST' })
|
||||||
|
if (data) exportResult.value = { count: data.count }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="developer-view">
|
||||||
|
<h2>Developer</h2>
|
||||||
|
|
||||||
|
<!-- Tier override -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Tier Override</h3>
|
||||||
|
<p class="section-note">Override the effective tier for UI testing. Does not affect licensing.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Override Tier</label>
|
||||||
|
<select v-model="devTierOverride">
|
||||||
|
<option :value="null">— none (use real tier) —</option>
|
||||||
|
<option v-for="t in TIERS" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveTierOverride" :disabled="saving" class="btn-primary">Apply Override</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HF Token -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>HuggingFace Token</h3>
|
||||||
|
<p class="section-note">Required for model downloads and fine-tune uploads.</p>
|
||||||
|
<p v-if="hfTokenSet" class="token-set">✓ Token stored securely</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Token</label>
|
||||||
|
<input v-model="hfTokenInput" type="password" placeholder="hf_…" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveHfToken" :disabled="!hfTokenInput" class="btn-primary">Save Token</button>
|
||||||
|
<button @click="testHfToken" class="btn-secondary">Test</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hfTestResult" :class="hfTestResult.ok ? 'status-ok' : 'error-msg'">
|
||||||
|
{{ hfTestResult.ok ? `✓ Logged in as ${hfTestResult.username}` : '✗ ' + hfTestResult.error }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Wizard reset -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Wizard</h3>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showWizardResetConfirm = true" class="btn-warning">Reset Setup Wizard</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showWizardResetConfirm" class="modal-overlay" @click.self="showWizardResetConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Reset Setup Wizard?</h3>
|
||||||
|
<p>The first-run setup wizard will be shown again on next launch.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="resetWizard" class="btn-warning">Reset</button>
|
||||||
|
<button @click="showWizardResetConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export classifier data -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Export Training Data</h3>
|
||||||
|
<p class="section-note">Export labeled emails as JSONL for classifier training.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="exportClassifier" class="btn-secondary">Export to data/email_score.jsonl</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="exportResult" class="status-ok">Exported {{ exportResult.count }} labeled emails.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
163
web/src/views/settings/FineTuneView.vue
Normal file
163
web/src/views/settings/FineTuneView.vue
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useFineTuneStore } from '../../stores/settings/fineTune'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const store = useFineTuneStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const uploadResult = ref<{ file_count: number } | null>(null)
|
||||||
|
const extractError = ref<string | null>(null)
|
||||||
|
const modelReady = ref<boolean | null>(null)
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!selectedFiles.value.length) return
|
||||||
|
store.uploading = true
|
||||||
|
const form = new FormData()
|
||||||
|
for (const f of selectedFiles.value) form.append('files', f)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/fine-tune/upload', { method: 'POST', body: form })
|
||||||
|
uploadResult.value = await res.json()
|
||||||
|
store.step = 2
|
||||||
|
} catch {
|
||||||
|
extractError.value = 'Upload failed'
|
||||||
|
} finally {
|
||||||
|
store.uploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExtract() {
|
||||||
|
extractError.value = null
|
||||||
|
const res = await fetch('/api/settings/fine-tune/extract', { method: 'POST' })
|
||||||
|
if (!res.ok) { extractError.value = 'Extraction failed'; return }
|
||||||
|
store.step = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLocalModel() {
|
||||||
|
const res = await fetch('/api/settings/fine-tune/local-status')
|
||||||
|
const data = await res.json()
|
||||||
|
modelReady.value = data.model_ready
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
store.startPolling()
|
||||||
|
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
||||||
|
})
|
||||||
|
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fine-tune-view">
|
||||||
|
<h2>Fine-Tune Model</h2>
|
||||||
|
|
||||||
|
<!-- Wizard steps indicator -->
|
||||||
|
<div class="wizard-steps">
|
||||||
|
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
|
||||||
|
<span class="step-divider">›</span>
|
||||||
|
<span :class="['step', step >= 2 ? 'active' : '']">2. Extract</span>
|
||||||
|
<span class="step-divider">›</span>
|
||||||
|
<span :class="['step', step >= 3 ? 'active' : '']">3. Train</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Upload -->
|
||||||
|
<section v-if="step === 1" class="form-section">
|
||||||
|
<h3>Upload Cover Letters</h3>
|
||||||
|
<p class="section-note">Upload .md or .txt cover letter files to build your training dataset.</p>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".md,.txt"
|
||||||
|
multiple
|
||||||
|
@change="selectedFiles = Array.from(($event.target as HTMLInputElement).files ?? [])"
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
@click="handleUpload"
|
||||||
|
:disabled="!selectedFiles.length || store.uploading"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ store.uploading ? 'Uploading…' : `Upload ${selectedFiles.length} file(s)` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Step 2: Extract pairs -->
|
||||||
|
<section v-else-if="step === 2" class="form-section">
|
||||||
|
<h3>Extract Training Pairs</h3>
|
||||||
|
<p v-if="uploadResult">{{ uploadResult.file_count }} file(s) uploaded.</p>
|
||||||
|
<p class="section-note">Extract job description + cover letter pairs for training.</p>
|
||||||
|
<p v-if="pairsCount > 0" class="pairs-count">{{ pairsCount }} pairs extracted so far.</p>
|
||||||
|
<p v-if="extractError" class="error-msg">{{ extractError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleExtract" :disabled="inFlightJob" class="btn-primary">
|
||||||
|
{{ inFlightJob ? 'Extracting…' : 'Extract Pairs' }}
|
||||||
|
</button>
|
||||||
|
<button @click="store.step = 3" class="btn-secondary">Skip → Train</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Step 3: Train -->
|
||||||
|
<section v-else class="form-section">
|
||||||
|
<h3>Train Model</h3>
|
||||||
|
<p class="pairs-count">{{ pairsCount }} training pairs available.</p>
|
||||||
|
|
||||||
|
<!-- Job status banner (if in-flight) -->
|
||||||
|
<div v-if="inFlightJob" class="status-banner status-running">
|
||||||
|
Job {{ jobStatus }} — polling every 2s…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="jobStatus === 'completed'" class="status-banner status-ok">
|
||||||
|
Training complete.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="jobStatus === 'failed'" class="status-banner status-fail">
|
||||||
|
Training failed. Check logs.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Self-hosted path -->
|
||||||
|
<template v-if="!config.isCloud">
|
||||||
|
<p class="section-note">Run locally with Unsloth + Ollama:</p>
|
||||||
|
<pre class="code-block">make finetune</pre>
|
||||||
|
<div v-if="modelReady === null" class="form-actions">
|
||||||
|
<button @click="checkLocalModel" class="btn-secondary">Check Model Status</button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="modelReady" class="status-ok">✓ alex-cover-writer model is ready in Ollama.</p>
|
||||||
|
<p v-else class="status-fail">Model not yet registered. Run <code>make finetune</code> first.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cloud path -->
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="quotaRemaining !== null" class="section-note">
|
||||||
|
Cloud quota remaining: {{ quotaRemaining }} jobs
|
||||||
|
</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
@click="store.submitJob()"
|
||||||
|
:disabled="inFlightJob || pairsCount === 0"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ inFlightJob ? 'Job queued…' : 'Submit Training Job' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fine-tune-view { max-width: 640px; }
|
||||||
|
.wizard-steps { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.step { padding: 0.25rem 0.75rem; border-radius: 99px; background: var(--color-surface-2, #eee); color: var(--color-text-muted, #888); }
|
||||||
|
.step.active { background: var(--color-accent, #3b82f6); color: #fff; }
|
||||||
|
.step-divider { color: var(--color-text-muted, #888); }
|
||||||
|
.file-input { display: block; margin: 0.75rem 0; }
|
||||||
|
.pairs-count { font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.code-block { background: var(--color-surface-2, #f5f5f5); padding: 0.75rem 1rem; border-radius: 6px; font-family: monospace; margin: 0.75rem 0; }
|
||||||
|
.status-banner { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||||
|
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
|
||||||
|
.status-ok { color: var(--color-success, #16a34a); }
|
||||||
|
.status-fail { color: var(--color-error, #dc2626); }
|
||||||
|
</style>
|
||||||
18
web/src/views/settings/LicenseCloud.vue
Normal file
18
web/src/views/settings/LicenseCloud.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const { tier } = storeToRefs(useAppConfigStore())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Plan</h2>
|
||||||
|
<div class="license-info">
|
||||||
|
<span class="tier-badge">{{ tier?.toUpperCase() ?? 'FREE' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="section-note">
|
||||||
|
Manage your subscription at <a href="https://circuitforge.tech/account" target="_blank">circuitforge.tech/account</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useLicenseStore } from '../../stores/settings/license'
|
||||||
|
|
||||||
|
const store = useLicenseStore()
|
||||||
|
const { tier, licenseKey, active, gracePeriodEnds, activating, activateError } = storeToRefs(store)
|
||||||
|
const keyInput = ref('')
|
||||||
|
const showDeactivateConfirm = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => store.loadLicense())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>License</h2>
|
||||||
|
|
||||||
|
<!-- Active license -->
|
||||||
|
<template v-if="active">
|
||||||
|
<div class="license-info">
|
||||||
|
<span :class="`tier-badge tier-${tier}`">{{ tier.toUpperCase() }}</span>
|
||||||
|
<span v-if="licenseKey" class="license-key">{{ licenseKey }}</span>
|
||||||
|
<span v-if="gracePeriodEnds" class="grace-notice">Grace period ends: {{ gracePeriodEnds }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showDeactivateConfirm = true" class="btn-danger">Deactivate</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showDeactivateConfirm" class="modal-overlay" @click.self="showDeactivateConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Deactivate License?</h3>
|
||||||
|
<p>You will lose access to paid features. You can reactivate later with the same key.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="store.deactivate(); showDeactivateConfirm = false" class="btn-danger">Deactivate</button>
|
||||||
|
<button @click="showDeactivateConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No active license -->
|
||||||
|
<template v-else>
|
||||||
|
<p class="section-note">Enter your license key to unlock paid features.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>License Key</label>
|
||||||
|
<input v-model="keyInput" placeholder="CFG-PRNG-XXXX-XXXX-XXXX" class="monospace" />
|
||||||
|
</div>
|
||||||
|
<p v-if="activateError" class="error-msg">{{ activateError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.activate(keyInput)" :disabled="!keyInput || activating" class="btn-primary">
|
||||||
|
{{ activating ? 'Activating…' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
web/src/views/settings/LicenseView.vue
Normal file
16
web/src/views/settings/LicenseView.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import LicenseSelfHosted from './LicenseSelfHosted.vue'
|
||||||
|
import LicenseCloud from './LicenseCloud.vue'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const isCloud = computed(() => config.isCloud)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="license-view">
|
||||||
|
<LicenseCloud v-if="isCloud" />
|
||||||
|
<LicenseSelfHosted v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
577
web/src/views/settings/MyProfileView.vue
Normal file
577
web/src/views/settings/MyProfileView.vue
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
<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>
|
||||||
|
<div v-if="loadError" class="load-error-banner" role="alert">
|
||||||
|
<strong>Error loading profile:</strong> {{ loadError }}
|
||||||
|
</div>
|
||||||
|
<!-- ── 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="pref.id"
|
||||||
|
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="company"
|
||||||
|
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 { storeToRefs } from 'pinia'
|
||||||
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const store = useProfileStore()
|
||||||
|
const { loadError } = storeToRefs(store)
|
||||||
|
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, { id: crypto.randomUUID(), 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 trimmed = newNdaCompany.value.trim()
|
||||||
|
if (!trimmed || store.nda_companies.includes(trimmed)) return
|
||||||
|
store.nda_companies = [...store.nda_companies, trimmed]
|
||||||
|
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
|
||||||
|
const { data, error } = await useApiFetch<{ summary?: string }>(
|
||||||
|
'/api/settings/profile/generate-summary', { method: 'POST' }
|
||||||
|
)
|
||||||
|
generatingSummary.value = false
|
||||||
|
if (!error && data?.summary) store.career_summary = data.summary
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateMissions() {
|
||||||
|
generatingMissions.value = true
|
||||||
|
const { data, error } = await useApiFetch<{ mission_preferences?: Array<{ industry: string; note: string }> }>(
|
||||||
|
'/api/settings/profile/generate-missions', { method: 'POST' }
|
||||||
|
)
|
||||||
|
generatingMissions.value = false
|
||||||
|
if (!error && data?.mission_preferences) {
|
||||||
|
store.mission_preferences = data.mission_preferences.map((m) => ({
|
||||||
|
id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-error-banner {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
background: color-mix(in srgb, var(--color-danger, #c0392b) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger, #c0392b) 40%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-danger, #c0392b);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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>
|
||||||
82
web/src/views/settings/PrivacyView.vue
Normal file
82
web/src/views/settings/PrivacyView.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { usePrivacyStore } from '../../stores/settings/privacy'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
|
|
||||||
|
const privacy = usePrivacyStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const system = useSystemStore()
|
||||||
|
const { telemetryOptIn, masterOff, usageEvents, contentSharing, showByokPanel, saving } = storeToRefs(privacy)
|
||||||
|
|
||||||
|
// Sync active cloud backends from system store into privacy store
|
||||||
|
const activeCloudBackends = computed(() =>
|
||||||
|
system.backends.filter(b => b.enabled && ['anthropic', 'openai'].includes(b.id)).map(b => b.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await privacy.loadPrivacy()
|
||||||
|
privacy.activeCloudBackends = activeCloudBackends.value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (config.isCloud) {
|
||||||
|
await privacy.savePrivacy({ master_off: masterOff.value, usage_events: usageEvents.value, content_sharing: contentSharing.value })
|
||||||
|
} else {
|
||||||
|
await privacy.savePrivacy({ telemetry_opt_in: telemetryOptIn.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="privacy-view">
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
|
<!-- Self-hosted -->
|
||||||
|
<template v-if="!config.isCloud">
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Telemetry</h3>
|
||||||
|
<p class="section-note">Peregrine is fully local by default — no data leaves your machine unless you opt in.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="telemetryOptIn" />
|
||||||
|
Share anonymous usage statistics to help improve Peregrine
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BYOK Info Panel -->
|
||||||
|
<section v-if="showByokPanel" class="form-section byok-panel">
|
||||||
|
<h3>Cloud LLM Privacy Notice</h3>
|
||||||
|
<p>You have cloud LLM backends enabled. Your job descriptions and cover letter content will be sent to those providers' APIs. Peregrine never logs this content, but the providers' own data policies apply.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="privacy.dismissByokInfo()" class="btn-secondary">Got it, don't show again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cloud -->
|
||||||
|
<template v-else>
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Data Controls</h3>
|
||||||
|
<label class="checkbox-row danger">
|
||||||
|
<input type="checkbox" v-model="masterOff" />
|
||||||
|
Disable all data collection (master off)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="usageEvents" :disabled="masterOff" />
|
||||||
|
Usage events (feature analytics)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="contentSharing" :disabled="masterOff" />
|
||||||
|
Share content for model improvement
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleSave" :disabled="saving" class="btn-primary">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
310
web/src/views/settings/ResumeProfileView.vue
Normal file
310
web/src/views/settings/ResumeProfileView.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
<template>
|
||||||
|
<div class="resume-profile">
|
||||||
|
<h2>Resume Profile</h2>
|
||||||
|
|
||||||
|
<!-- Load error banner -->
|
||||||
|
<div v-if="loadError" class="error-banner">
|
||||||
|
Failed to load resume: {{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Date of Birth</label>
|
||||||
|
<input v-model="store.date_of_birth" type="date" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Work Experience -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Work Experience</h3>
|
||||||
|
<div v-for="(entry, idx) in store.experience" :key="entry.id" 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="store.removeExperience(idx)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<button @click="store.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="store.removeTag('skills', skill)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="skillInput" @keydown.enter.prevent="store.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="store.removeTag('domains', domain)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="domainInput" @keydown.enter.prevent="store.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="store.removeTag('keywords', kw)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="kwInput" @keydown.enter.prevent="store.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 { storeToRefs } from 'pinia'
|
||||||
|
import { useResumeStore } from '../../stores/settings/resume'
|
||||||
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const store = useResumeStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const { loadError } = storeToRefs(store)
|
||||||
|
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()
|
||||||
|
// Only prime identity from profile on a fresh/empty resume
|
||||||
|
if (!store.hasResume) {
|
||||||
|
store.syncFromProfile({
|
||||||
|
name: profileStore.name,
|
||||||
|
email: profileStore.email,
|
||||||
|
phone: profileStore.phone,
|
||||||
|
linkedin_url: profileStore.linkedin_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? '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; }
|
||||||
|
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
|
||||||
|
.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>
|
||||||
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" :checked="board.enabled" @change="store.toggleBoard(board.name)" />
|
||||||
|
{{ 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>
|
||||||
45
web/src/views/settings/SettingsView.test.ts
Normal file
45
web/src/views/settings/SettingsView.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import SettingsView from './SettingsView.vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return createRouter({ history: createWebHistory(), routes: [{ path: '/:p*', component: { template: '<div/>' } }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SettingsView sidebar', () => {
|
||||||
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
|
|
||||||
|
it('hides System group items in cloud mode', async () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = true
|
||||||
|
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
|
||||||
|
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows System when not cloud', async () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isCloud = false
|
||||||
|
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
|
||||||
|
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Developer when neither devMode nor devTierOverride', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isDevMode = false
|
||||||
|
localStorage.removeItem('dev_tier_override')
|
||||||
|
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
|
||||||
|
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Developer when devTierOverride is set in store', () => {
|
||||||
|
const store = useAppConfigStore()
|
||||||
|
store.isDevMode = false
|
||||||
|
store.setDevTierOverride('premium')
|
||||||
|
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
|
||||||
|
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(true)
|
||||||
|
store.setDevTierOverride(null) // cleanup
|
||||||
|
})
|
||||||
|
})
|
||||||
156
web/src/views/settings/SettingsView.vue
Normal file
156
web/src/views/settings/SettingsView.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<template>
|
||||||
|
<div class="settings-layout">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<nav class="settings-sidebar" aria-label="Settings navigation">
|
||||||
|
<template v-for="group in visibleGroups" :key="group.label">
|
||||||
|
<div class="nav-group-label">{{ group.label }}</div>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
:data-testid="`nav-${item.key}`"
|
||||||
|
class="nav-item"
|
||||||
|
active-class="nav-item--active"
|
||||||
|
>{{ item.label }}</RouterLink>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile chip bar -->
|
||||||
|
<div class="settings-chip-bar" role="tablist">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in visibleTabs"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="chip"
|
||||||
|
active-class="chip--active"
|
||||||
|
role="tab"
|
||||||
|
>{{ item.label }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="settings-content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const devOverride = computed(() => !!config.devTierOverride)
|
||||||
|
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
||||||
|
|
||||||
|
const showSystem = computed(() => !config.isCloud)
|
||||||
|
const showFineTune = computed(() => {
|
||||||
|
if (config.isCloud) return config.tier === 'premium'
|
||||||
|
return gpuProfiles.includes(config.inferenceProfile)
|
||||||
|
})
|
||||||
|
const showDeveloper = computed(() => config.isDevMode || devOverride.value)
|
||||||
|
|
||||||
|
// IMPORTANT: `show` values must be ComputedRef<boolean> objects (e.g. showSystem),
|
||||||
|
// NOT raw booleans (e.g. showSystem.value). Using .value here would capture a static
|
||||||
|
// boolean at setup time and break reactivity.
|
||||||
|
const allGroups = [
|
||||||
|
{ label: 'Profile', items: [
|
||||||
|
{ key: 'my-profile', path: '/settings/my-profile', label: 'My Profile', show: true },
|
||||||
|
{ key: 'resume', path: '/settings/resume', label: 'Resume Profile', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'Search', items: [
|
||||||
|
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'App', items: [
|
||||||
|
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
|
||||||
|
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
|
||||||
|
]},
|
||||||
|
{ label: 'Account', items: [
|
||||||
|
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
||||||
|
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
|
||||||
|
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'Dev', items: [
|
||||||
|
{ key: 'developer', path: '/settings/developer', label: 'Developer', show: showDeveloper },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleGroups = computed(() =>
|
||||||
|
allGroups
|
||||||
|
.map(g => ({ ...g, items: g.items.filter(i => i.show === true || (typeof i.show !== 'boolean' && i.show.value)) }))
|
||||||
|
.filter(g => g.items.length > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTabs = computed(() => visibleGroups.value.flatMap(g => g.items))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-height: calc(100vh - var(--header-height, 56px));
|
||||||
|
}
|
||||||
|
.settings-sidebar {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
.nav-group-label {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-1);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-right: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item--active {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-right-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.settings-chip-bar {
|
||||||
|
display: none;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black 85%, transparent);
|
||||||
|
mask-image: linear-gradient(to right, black 85%, transparent);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chip--active {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.settings-content {
|
||||||
|
grid-column: 2;
|
||||||
|
padding: var(--space-6) var(--space-8);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
|
.settings-sidebar { display: none; }
|
||||||
|
.settings-chip-bar { display: flex; }
|
||||||
|
.settings-content { grid-column: 1; padding: var(--space-4); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
394
web/src/views/settings/SystemSettingsView.vue
Normal file
394
web/src/views/settings/SystemSettingsView.vue
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
<template>
|
||||||
|
<div class="system-settings">
|
||||||
|
<h2>System Settings</h2>
|
||||||
|
<p class="tab-note">This tab is only available in self-hosted mode.</p>
|
||||||
|
|
||||||
|
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
|
||||||
|
|
||||||
|
<!-- LLM Backends -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>LLM Backends</h3>
|
||||||
|
<p class="section-note">Drag to reorder. Higher position = higher priority in the fallback chain.</p>
|
||||||
|
|
||||||
|
<div class="backend-list">
|
||||||
|
<div
|
||||||
|
v-for="(backend, idx) in visibleBackends"
|
||||||
|
:key="backend.id"
|
||||||
|
class="backend-card"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart(idx)"
|
||||||
|
@dragover.prevent="dragOver(idx)"
|
||||||
|
@drop="drop"
|
||||||
|
>
|
||||||
|
<span class="drag-handle" aria-hidden="true">⠿</span>
|
||||||
|
<span class="priority-badge">{{ idx + 1 }}</span>
|
||||||
|
<span class="backend-id">{{ backend.id }}</span>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="backend.enabled"
|
||||||
|
@change="store.backends = store.backends.map(b =>
|
||||||
|
b.id === backend.id ? { ...b, enabled: !b.enabled } : b
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
<span class="toggle-text">{{ backend.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.trySave()" :disabled="store.saving" class="btn-primary">
|
||||||
|
{{ store.saving ? 'Saving…' : 'Save Backends' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Services section -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Services</h3>
|
||||||
|
<p class="section-note">Port-based status. Start/Stop via Docker Compose.</p>
|
||||||
|
<div class="service-grid">
|
||||||
|
<div v-for="svc in store.services" :key="svc.name" class="service-card">
|
||||||
|
<div class="service-header">
|
||||||
|
<span class="service-dot" :class="svc.running ? 'dot-running' : 'dot-stopped'"></span>
|
||||||
|
<span class="service-name">{{ svc.name }}</span>
|
||||||
|
<span class="service-port">:{{ svc.port }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="service-note">{{ svc.note }}</p>
|
||||||
|
<div class="service-actions">
|
||||||
|
<button v-if="!svc.running" @click="store.startService(svc.name)" class="btn-start">Start</button>
|
||||||
|
<button v-else @click="store.stopService(svc.name)" class="btn-stop">Stop</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.serviceErrors[svc.name]" class="error">{{ store.serviceErrors[svc.name] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Email section -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Email (IMAP)</h3>
|
||||||
|
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>IMAP Host</label>
|
||||||
|
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Port</label>
|
||||||
|
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
|
||||||
|
</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Username</label>
|
||||||
|
<input v-model="(store.emailConfig as any).username" type="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Password / App Password</label>
|
||||||
|
<input
|
||||||
|
v-model="emailPasswordInput"
|
||||||
|
type="password"
|
||||||
|
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
|
||||||
|
/>
|
||||||
|
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Sent Folder</label>
|
||||||
|
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Lookback Days</label>
|
||||||
|
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
||||||
|
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
||||||
|
</button>
|
||||||
|
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
||||||
|
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
|
||||||
|
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
|
||||||
|
</span>
|
||||||
|
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Integrations -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Integrations</h3>
|
||||||
|
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
|
||||||
|
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
||||||
|
<div class="integration-header">
|
||||||
|
<span class="integration-name">{{ integration.name }}</span>
|
||||||
|
<div class="integration-badges">
|
||||||
|
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
|
||||||
|
Requires {{ integration.tier_required }}
|
||||||
|
</span>
|
||||||
|
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
||||||
|
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Locked state for insufficient tier -->
|
||||||
|
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
|
||||||
|
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Normal state for sufficient tier -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!integration.connected" class="integration-form">
|
||||||
|
<div v-for="field in integration.fields" :key="field.key" class="field-row">
|
||||||
|
<label>{{ field.label }}</label>
|
||||||
|
<input v-model="integrationInputs[integration.id + ':' + field.key]"
|
||||||
|
:type="field.type === 'password' ? 'password' : 'text'" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
|
||||||
|
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
|
||||||
|
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
|
||||||
|
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- File Paths -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>File Paths</h3>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Documents Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).docs_dir" placeholder="/Library/Documents/JobSearch" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Data Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).data_dir" placeholder="data/" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Model Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).model_dir" placeholder="/Library/Assets/LLM" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.saveFilePaths()" :disabled="store.filePathsSaving" class="btn-primary">
|
||||||
|
{{ store.filePathsSaving ? 'Saving…' : 'Save Paths' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.filePathsError" class="error-msg">{{ store.filePathsError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Deployment / Server -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Deployment / Server</h3>
|
||||||
|
<p class="section-note">Restart required for changes to take effect.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Base URL Path</label>
|
||||||
|
<input v-model="(store.deployConfig as any).base_url_path" placeholder="/peregrine" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Server Host</label>
|
||||||
|
<input v-model="(store.deployConfig as any).server_host" placeholder="0.0.0.0" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Server Port</label>
|
||||||
|
<input v-model.number="(store.deployConfig as any).server_port" type="number" placeholder="8502" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.saveDeployConfig()" :disabled="store.deploySaving" class="btn-primary">
|
||||||
|
{{ store.deploySaving ? 'Saving…' : 'Save (requires restart)' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BYOK Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="byok-title">
|
||||||
|
<h3 id="byok-title">⚠️ Cloud LLM Key Required</h3>
|
||||||
|
<p>You are enabling the following cloud backends:</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="b in store.byokPending" :key="b">{{ b }}</li>
|
||||||
|
</ul>
|
||||||
|
<p class="byok-warning">
|
||||||
|
These services require your own API key. Your requests and data will be
|
||||||
|
sent to these third-party providers. Costs will be charged to your account.
|
||||||
|
</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="byokConfirmed" />
|
||||||
|
I understand and have configured my API key in <code>config/llm.yaml</code>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="store.cancelByok()" class="btn-cancel">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleConfirmByok"
|
||||||
|
:disabled="!byokConfirmed || store.saving"
|
||||||
|
class="btn-primary"
|
||||||
|
>{{ store.saving ? 'Saving…' : 'Save with Cloud LLM' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const store = useSystemStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const { tier } = storeToRefs(config)
|
||||||
|
|
||||||
|
const byokConfirmed = ref(false)
|
||||||
|
const dragIdx = ref<number | null>(null)
|
||||||
|
|
||||||
|
const CONTRACTED_ONLY = ['claude-code', 'copilot']
|
||||||
|
|
||||||
|
const visibleBackends = computed(() =>
|
||||||
|
store.backends.filter(b =>
|
||||||
|
!CONTRACTED_ONLY.includes(b.id) || config.contractedClient
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const tierOrder = ['free', 'paid', 'premium', 'ultra']
|
||||||
|
function meetsRequiredTier(required: string): boolean {
|
||||||
|
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragStart(idx: number) {
|
||||||
|
dragIdx.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(toFilteredIdx: number) {
|
||||||
|
if (dragIdx.value === null || dragIdx.value === toFilteredIdx) return
|
||||||
|
const fromId = visibleBackends.value[dragIdx.value].id
|
||||||
|
const toId = visibleBackends.value[toFilteredIdx].id
|
||||||
|
const arr = [...store.backends]
|
||||||
|
const fromFull = arr.findIndex(b => b.id === fromId)
|
||||||
|
const toFull = arr.findIndex(b => b.id === toId)
|
||||||
|
if (fromFull === -1 || toFull === -1) return
|
||||||
|
const [moved] = arr.splice(fromFull, 1)
|
||||||
|
arr.splice(toFull, 0, moved)
|
||||||
|
store.backends = arr.map((b, i) => ({ ...b, priority: i + 1 }))
|
||||||
|
dragIdx.value = toFilteredIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
dragIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmByok() {
|
||||||
|
await store.confirmByok()
|
||||||
|
byokConfirmed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailTestResult = ref<boolean | null>(null)
|
||||||
|
const emailPasswordInput = ref('')
|
||||||
|
const integrationInputs = ref<Record<string, string>>({})
|
||||||
|
async function handleTestEmail() {
|
||||||
|
const result = await store.testEmail()
|
||||||
|
emailTestResult.value = result?.ok ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEmail() {
|
||||||
|
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
|
||||||
|
await store.saveEmailWithPassword(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnect(id: string) {
|
||||||
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
|
if (!integration) return
|
||||||
|
const credentials: Record<string, string> = {}
|
||||||
|
for (const field of integration.fields) {
|
||||||
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
|
}
|
||||||
|
await store.connectIntegration(id, credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(id: string) {
|
||||||
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
|
if (!integration) return
|
||||||
|
const credentials: Record<string, string> = {}
|
||||||
|
for (const field of integration.fields) {
|
||||||
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
|
}
|
||||||
|
await store.testIntegration(id, credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadLlm()
|
||||||
|
await Promise.all([
|
||||||
|
store.loadServices(),
|
||||||
|
store.loadEmail(),
|
||||||
|
store.loadIntegrations(),
|
||||||
|
store.loadFilePaths(),
|
||||||
|
store.loadDeployConfig(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||||
|
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; 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); }
|
||||||
|
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
|
||||||
|
.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)); }
|
||||||
|
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
|
||||||
|
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; 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: 8px; cursor: grab; user-select: none; }
|
||||||
|
.backend-card:active { cursor: grabbing; }
|
||||||
|
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
|
||||||
|
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.form-actions { 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; }
|
||||||
|
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.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; }
|
||||||
|
/* BYOK Modal */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||||
|
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||||
|
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
|
||||||
|
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
|
||||||
|
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||||
|
.service-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: 8px; padding: 14px; }
|
||||||
|
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
||||||
|
.dot-stopped { background: #64748b; }
|
||||||
|
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
|
||||||
|
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||||
|
.service-actions { display: flex; gap: 6px; }
|
||||||
|
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
||||||
|
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.field-row 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: 7px 10px; font-size: 0.88rem; }
|
||||||
|
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
|
||||||
|
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
|
||||||
|
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
|
||||||
|
.test-ok { color: #22c55e; font-size: 0.85rem; }
|
||||||
|
.test-fail { color: #ef4444; font-size: 0.85rem; }
|
||||||
|
.integration-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: 8px; padding: 16px; margin-bottom: 12px; }
|
||||||
|
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
|
||||||
|
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
|
||||||
|
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
|
||||||
|
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.integration-badges { display: flex; align-items: center; gap: 4px; }
|
||||||
|
</style>
|
||||||
14
web/tsconfig.app.json
Normal file
14
web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
web/tsconfig.node.json
Normal file
22
web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "uno.config.ts"]
|
||||||
|
}
|
||||||
10
web/uno.config.ts
Normal file
10
web/uno.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig, presetWind, presetAttributify } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetWind(),
|
||||||
|
// prefixedOnly: avoids false-positive CSS for bare attribute names like "h2", "grid",
|
||||||
|
// "shadow" in source files. Use <div un-flex> not <div flex>. Gotcha #4.
|
||||||
|
presetAttributify({ prefix: 'un-', prefixedOnly: true }),
|
||||||
|
],
|
||||||
|
})
|
||||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import UnoCSS from 'unocss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), UnoCSS()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8601',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue