feat(web): implement design spec — peregrine.css, sidebar nav, HomeView
Applies the full design spec from circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md: CSS tokens: - Falcon Blue (#2B6CB0 / #68A8D8 dark) — was incorrectly using forest green - Talon Orange (#E06820 / #F6872A dark) with --app-accent-text dark navy (never white) - Full pipeline status token set (--status-pending/approve/reject/applied/synced/...) - Match score tokens, motion tokens, type scale tokens - Dark mode + hacker mode overrides AppNav: sidebar layout (replaces top bar) - Desktop ≥1024px: persistent sidebar with brand, links, hacker-exit, settings footer - Mobile <1024px: bottom tab bar with 5 primary destinations - Click-the-bird easter egg (9.6): 5 rapid clicks → ruffle animation - Heroicons via @heroicons/vue/24/outline App.vue: - Skip-to-content link (a11y) - Sidebar margin-left layout (desktop) / tab bar clearance (mobile) HomeView: full dashboard implementation - Pipeline metric cards (Pending/Approved/Applied/Synced/Rejected) with status colors - Primary workflow buttons (Run Discovery, Sync Emails, Score Unscored) + sync banner - Auto-enrichment status row - Backlog management (conditionally visible) - Add Jobs by URL / CSV upload tabs - Advanced/danger zone in collapsible <details> - Stoop speed toast easter egg (9.2) - Midnight mode greeting easter egg (9.7) WorkflowButton component with loading spinner, proper touch targets (min-height 44px) Pinia jobs store (setup form) with counts + system status Build: clean 2.28s, 0 errors
This commit is contained in:
parent
cc18927437
commit
8f1ad9176b
6 changed files with 1129 additions and 107 deletions
|
|
@ -1,22 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- IMPORTANT: root element uses class="app-root", NOT id="app".
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||||
index.html owns #app as the mount target.
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||||
Mixing the two creates nested #app elements with ambiguous CSS specificity.
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||||
Gotcha #1 from docs/vue-port-gotchas.md. -->
|
|
||||||
<div
|
|
||||||
class="app-root"
|
|
||||||
:class="{ 'rich-motion': motion.rich.value }"
|
|
||||||
:data-theme="hackerTheme"
|
|
||||||
>
|
|
||||||
<AppNav />
|
<AppNav />
|
||||||
<main class="app-main">
|
<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 />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { useMotion } from './composables/useMotion'
|
import { useMotion } from './composables/useMotion'
|
||||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||||
|
|
@ -25,13 +21,6 @@ import AppNav from './components/AppNav.vue'
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const { toggle, restore } = useHackerMode()
|
const { toggle, restore } = useHackerMode()
|
||||||
|
|
||||||
// Computed so template reactively tracks localStorage-driven theme
|
|
||||||
const hackerTheme = computed(() =>
|
|
||||||
typeof document !== 'undefined' && document.documentElement.dataset.theme === 'hacker'
|
|
||||||
? 'hacker'
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
useKonamiCode(toggle)
|
useKonamiCode(toggle)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -40,7 +29,7 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Global resets in <style> (no scoped) — applied once to the document */
|
/* Global resets — unscoped, applied once to document */
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -51,29 +40,55 @@ html {
|
||||||
font-family: var(--font-body, sans-serif);
|
font-family: var(--font-body, sans-serif);
|
||||||
color: var(--color-text, #1a2338);
|
color: var(--color-text, #1a2338);
|
||||||
background: var(--color-surface, #eaeff8);
|
background: var(--color-surface, #eaeff8);
|
||||||
/* clip (not hidden) — avoids BFC scroll-container side effects. Gotcha #3. */
|
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
|
||||||
overflow-x: clip;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100dvh; /* dvh = dynamic viewport height — mobile chrome-aware. Gotcha #13. */
|
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
||||||
overflow-x: hidden; /* body hidden is survivable; html must be clip */
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mount shell — thin container, no layout */
|
#app { min-height: 100dvh; }
|
||||||
#app {
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* App layout root */
|
/* Layout root — sidebar pushes content right on desktop */
|
||||||
.app-root {
|
.app-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0; /* prevents flex children from blowing out container width */
|
min-width: 0; /* prevents flex blowout */
|
||||||
padding-top: var(--nav-height, 4rem);
|
/* 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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,113 @@
|
||||||
/* web/src/assets/peregrine.css
|
/* web/src/assets/peregrine.css
|
||||||
Peregrine token overrides — imported AFTER theme.css.
|
Peregrine-specific token overrides — imported AFTER theme.css.
|
||||||
Only overrides what is genuinely different from the CircuitForge base theme.
|
Source of truth: circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md
|
||||||
|
|
||||||
App colors:
|
Brand:
|
||||||
Primary — Forest Green (#2d5a27) — inherited from theme.css --color-primary
|
Falcon Blue (#2B6CB0) — slate-blue back plumage of the peregrine falcon
|
||||||
Accent — Amber/Copper (#c4732a) — inherited from theme.css --color-accent
|
Talon Orange (#E06820) — vivid orange-yellow talons and cere
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ── Page-level overrides ───────────────────────────── */
|
/* ── Page-level ─────────────────────────────────────── */
|
||||||
html {
|
html {
|
||||||
/* Prevent Mac Chrome horizontal swipe-to-navigate on viewport edge */
|
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
/* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
|
overflow-x: clip; /* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
|
||||||
overflow-x: clip;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* Suppress horizontal scroll from animated transitions */
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Light mode (default) ──────────────────────────── */
|
/* ── Light mode (default) ──────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
/* Alias map — component-expected names → theme.css canonical names. Gotcha #5.
|
/* ── Peregrine brand colors ── */
|
||||||
Components should prefer the theme.css names; add aliases only when needed. */
|
--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-bg: var(--color-surface);
|
||||||
--color-text-secondary: var(--color-text-muted);
|
--color-text-secondary: var(--color-text-muted);
|
||||||
|
|
||||||
/* Nav height token — consumed by .app-main padding-top in App.vue */
|
/* ── Layout ── */
|
||||||
--nav-height: 4rem;
|
--nav-height: 4rem; /* top bar height (mobile) */
|
||||||
|
--sidebar-width: 220px; /* persistent sidebar (≥1024px) */
|
||||||
|
|
||||||
/* Motion tokens for future animated components (inspired by avocet bucket pattern) */
|
/* ── 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: var(--color-warning); /* 40–69% */
|
||||||
|
--score-low: var(--color-error); /* < 40% */
|
||||||
|
--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-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
--transition-dismiss: 350ms ease-in;
|
--transition-dismiss: 350ms ease-in;
|
||||||
--transition-enter: 250ms ease-out;
|
--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 ─────────────────────────────────────── */
|
/* ── Dark mode ─────────────────────────────────────── */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="hacker"]) {
|
:root:not([data-theme="hacker"]) {
|
||||||
/* Aliases inherit dark values from theme.css automatically */
|
--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;
|
||||||
|
|
||||||
|
--status-synced: #9b8fea;
|
||||||
|
--status-survey: #b08fea;
|
||||||
|
--status-phone: #4ec9be;
|
||||||
|
--status-offer: #f5a43a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hacker mode (Konami easter egg) ──────────────── */
|
/* ── Hacker mode (Konami easter egg) ──────────────── */
|
||||||
/* Applied via document.documentElement.dataset.theme = 'hacker' */
|
|
||||||
/* Full token overrides live in theme.css [data-theme="hacker"] block */
|
|
||||||
[data-theme="hacker"] {
|
[data-theme="hacker"] {
|
||||||
/* Cursor trail uses this color — override for hacker palette */
|
--app-primary: #00ff41;
|
||||||
--color-accent: #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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,60 @@
|
||||||
<template>
|
<template>
|
||||||
<nav class="app-nav" role="navigation" aria-label="Main navigation">
|
<!-- Desktop: persistent sidebar (≥1024px) -->
|
||||||
<div class="app-nav__brand">
|
<!-- Mobile: bottom tab bar (<1024px) -->
|
||||||
<RouterLink to="/" class="app-nav__logo">Peregrine</RouterLink>
|
<!-- 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>
|
</div>
|
||||||
<ul class="app-nav__links" role="list">
|
|
||||||
|
<!-- Nav links -->
|
||||||
|
<ul class="sidebar__links" role="list">
|
||||||
<li v-for="link in navLinks" :key="link.to">
|
<li v-for="link in navLinks" :key="link.to">
|
||||||
<RouterLink :to="link.to" class="app-nav__link" active-class="app-nav__link--active">
|
<RouterLink
|
||||||
<span class="app-nav__icon" aria-hidden="true">{{ link.icon }}</span>
|
:to="link.to"
|
||||||
<span class="app-nav__label">{{ link.label }}</span>
|
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>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -15,75 +62,251 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
ClipboardDocumentListIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
LightBulbIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
|
// 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 = [
|
const navLinks = [
|
||||||
{ to: '/', icon: '🏠', label: 'Home' },
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
{ to: '/review', icon: '📋', label: 'Job Review' },
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
{ to: '/apply', icon: '✍️', label: 'Apply' },
|
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||||
{ to: '/interviews', icon: '🗓️', label: 'Interviews' },
|
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||||
{ to: '/prep', icon: '🎯', label: 'Interview Prep' },
|
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||||
{ to: '/survey', icon: '🔍', label: 'Survey' },
|
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
|
||||||
{ to: '/settings', icon: '⚙️', label: 'Settings' },
|
]
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-nav {
|
/* ── Sidebar (desktop ≥1024px) ──────────────────────── */
|
||||||
|
.app-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
bottom: 0;
|
||||||
height: var(--nav-height, 4rem);
|
width: var(--sidebar-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
|
||||||
padding: 0 var(--space-6);
|
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
border-bottom: 2px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__brand { flex-shrink: 0; }
|
.sidebar__brand {
|
||||||
|
padding: 0 var(--space-4) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__logo {
|
.sidebar__logo {
|
||||||
font-family: var(--font-display);
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
font-size: 1.2rem;
|
gap: var(--space-2);
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__links {
|
/* Click-the-bird ruffle animation — easter egg 9.6 */
|
||||||
display: flex;
|
.sidebar__bird {
|
||||||
gap: var(--space-2);
|
font-size: 1.4rem;
|
||||||
list-style: none;
|
display: inline-block;
|
||||||
margin: 0;
|
transform-origin: center bottom;
|
||||||
padding: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__link {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.875rem;
|
font-size: var(--text-sm);
|
||||||
white-space: nowrap;
|
font-weight: 500;
|
||||||
/* Enumerate only the properties that animate — no transition:all with spring easing. Gotcha #2. */
|
min-height: 44px; /* WCAG 2.5.5 touch target */
|
||||||
|
/* Enumerate properties explicitly — no transition:all. Gotcha #2. */
|
||||||
transition:
|
transition:
|
||||||
background 150ms ease,
|
background 150ms ease,
|
||||||
color 150ms ease;
|
color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__link:hover,
|
.sidebar__link:hover {
|
||||||
.app-nav__link--active {
|
background: var(--app-primary-light);
|
||||||
background: var(--color-primary-light);
|
color: var(--app-primary);
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__icon { font-size: 1rem; }
|
.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>
|
</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/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 }
|
||||||
|
})
|
||||||
|
|
@ -1,18 +1,595 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="view-placeholder">
|
<div class="home">
|
||||||
<h1>HomeView</h1>
|
<!-- Header -->
|
||||||
<p class="placeholder-note">Vue port in progress — Streamlit equivalent at app/pages/</p>
|
<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 class="action-btn action-btn--secondary" @click="archivePendingRejected">
|
||||||
|
📦 Archive Pending + Rejected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="(store.counts?.approved ?? 0) > 0"
|
||||||
|
class="action-btn action-btn--secondary"
|
||||||
|
@click="archiveApproved"
|
||||||
|
>
|
||||||
|
📦 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
.view-placeholder {
|
import { ref, computed, onMounted } from 'vue'
|
||||||
padding: var(--space-8);
|
import { RouterLink } from 'vue-router'
|
||||||
max-width: 60ch;
|
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`
|
||||||
}
|
}
|
||||||
.placeholder-note {
|
|
||||||
color: var(--color-text-muted);
|
const taskRunning = ref<string | null>(null)
|
||||||
font-size: 0.875rem;
|
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 archivePendingRejected() {
|
||||||
|
await useApiFetch('/api/jobs/archive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statuses: ['pending', 'rejected'] }),
|
||||||
|
})
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveApproved() {
|
||||||
|
await useApiFetch('/api/jobs/archive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statuses: ['approved'] }),
|
||||||
|
})
|
||||||
|
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);
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue