feat(vue-spa): JobReviewView card stack with swipe gestures
- stores/review.ts: Pinia setup store — pending queue, undo stack, stoop-speed session timer (easter egg 9.2: 10 cards/60s) - components/JobCard.vue: card content with match-score badge (colored pill), keyword-gap pills, expand/collapse description, footer with job URL + relative date; shimmer animation for ≥95% matches (ee 9.4) - components/JobCardStack.vue: pointer-event drag with setPointerCapture, rolling 50ms velocity buffer for fling detection (600px/s + cos45° alignment), left/right color-tint overlay (red/green), spring snap-back on no-action, buffered exit animation before emitting approve/reject - views/JobReviewView.vue: segmented status tabs, card stack for pending, list view for other statuses, action buttons, keyboard shortcuts (←/J reject, →/L approve, S skip, Z undo, ? help), help overlay, undo toast (5s), falcon stoop empty state (easter egg 9.3)
This commit is contained in:
parent
f3ce46e252
commit
75cc0760e1
6 changed files with 1560 additions and 9 deletions
10
web/package-lock.json
generated
10
web/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
||||||
"@fontsource/fraunces": "^5.2.9",
|
"@fontsource/fraunces": "^5.2.9",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"@vueuse/integrations": "^14.2.1",
|
"@vueuse/integrations": "^14.2.1",
|
||||||
"animejs": "^4.3.6",
|
"animejs": "^4.3.6",
|
||||||
|
|
@ -828,6 +829,15 @@
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@heroicons/vue": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">= 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@iconify/types": {
|
"node_modules/@iconify/types": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
||||||
"@fontsource/fraunces": "^5.2.9",
|
"@fontsource/fraunces": "^5.2.9",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"@vueuse/integrations": "^14.2.1",
|
"@vueuse/integrations": "^14.2.1",
|
||||||
"animejs": "^4.3.6",
|
"animejs": "^4.3.6",
|
||||||
|
|
|
||||||
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>
|
||||||
298
web/src/components/JobCardStack.vue
Normal file
298
web/src/components/JobCardStack.vue
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<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) {
|
||||||
|
wrapperEl.value.style.transform = ''
|
||||||
|
wrapperEl.value.style.opacity = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,18 +1,834 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="view-placeholder">
|
<div class="review">
|
||||||
<h1>JobReviewView</h1>
|
<!-- Header -->
|
||||||
<p class="placeholder-note">Vue port in progress — Streamlit equivalent at app/pages/</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
.view-placeholder {
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
padding: var(--space-8);
|
import { useRoute } from 'vue-router'
|
||||||
max-width: 60ch;
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.placeholder-note {
|
|
||||||
|
// ─── 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);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.875rem;
|
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);
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue