Compare commits
No commits in common. "8c3c0340ff1ca00352cb83e2d248cd79e2cda043" and "12ff809bd5be232397471318607e822bf717ba74" have entirely different histories.
8c3c0340ff
...
12ff809bd5
8 changed files with 4 additions and 484 deletions
|
|
@ -608,7 +608,7 @@ def queue_digest_jobs(digest_id: int, body: QueueJobsBody):
|
||||||
if not url or not url.startswith(('http://', 'https://')):
|
if not url or not url.startswith(('http://', 'https://')):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
result = insert_job(Path(DB_PATH), {
|
result = insert_job(DB_PATH, {
|
||||||
'url': url,
|
'url': url,
|
||||||
'title': '',
|
'title': '',
|
||||||
'company': '',
|
'company': '',
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,14 @@ 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'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
import { useDigestStore } from './stores/digest'
|
|
||||||
|
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const { toggle, restore } = useHackerMode()
|
const { toggle, restore } = useHackerMode()
|
||||||
const digestStore = useDigestStore()
|
|
||||||
|
|
||||||
useKonamiCode(toggle)
|
useKonamiCode(toggle)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restore() // re-apply hacker mode from localStorage on hard reload
|
restore() // re-apply hacker mode from localStorage on hard reload
|
||||||
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
>
|
>
|
||||||
<component :is="link.icon" class="sidebar__icon" aria-hidden="true" />
|
<component :is="link.icon" class="sidebar__icon" aria-hidden="true" />
|
||||||
<span class="sidebar__label">{{ link.label }}</span>
|
<span class="sidebar__label">{{ link.label }}</span>
|
||||||
<span v-if="link.badge" class="sidebar__badge" :aria-label="`${link.badge} items`">{{ link.badge }}</span>
|
<span v-if="link.badge" class="sidebar__badge" aria-label="`${link.badge} items`">{{ link.badge }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -71,13 +71,9 @@ import {
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
LightBulbIcon,
|
LightBulbIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
NewspaperIcon,
|
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
import { useDigestStore } from '../stores/digest'
|
|
||||||
const digestStore = useDigestStore()
|
|
||||||
|
|
||||||
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
||||||
const logoClickCount = ref(0)
|
const logoClickCount = ref(0)
|
||||||
const ruffling = ref(false)
|
const ruffling = ref(false)
|
||||||
|
|
@ -105,16 +101,14 @@ function exitHackerMode() {
|
||||||
localStorage.removeItem('cf-hacker-mode')
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = [
|
||||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||||
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||||
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
|
|
||||||
badge: digestStore.entries.length || undefined },
|
|
||||||
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||||
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
|
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
|
||||||
])
|
]
|
||||||
|
|
||||||
// Mobile: only the 5 most-used views
|
// Mobile: only the 5 most-used views
|
||||||
const mobileLinks = [
|
const mobileLinks = [
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,6 @@ async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_s
|
||||||
body: JSON.stringify({ stage_signal: newLabel }),
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
})
|
})
|
||||||
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
|
|
||||||
if (newLabel === 'digest') {
|
|
||||||
void useApiFetch('/api/digest-queue', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ job_contact_id: sig.id }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const prev = sig.stage_signal
|
const prev = sig.stage_signal
|
||||||
sig.stage_signal = newLabel
|
sig.stage_signal = newLabel
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ export const router = createRouter({
|
||||||
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
|
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
|
||||||
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||||
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||||
{ path: '/digest', component: () => import('../views/DigestView.vue') },
|
|
||||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
|
|
||||||
export interface DigestEntry {
|
|
||||||
id: number
|
|
||||||
job_contact_id: number
|
|
||||||
created_at: string
|
|
||||||
subject: string
|
|
||||||
from_addr: string | null
|
|
||||||
received_at: string
|
|
||||||
body: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extracted link from a digest email body. Used by DigestView.vue. */
|
|
||||||
export interface DigestLink {
|
|
||||||
url: string
|
|
||||||
score: number // 2 = job-likely, 1 = other
|
|
||||||
hint: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDigestStore = defineStore('digest', () => {
|
|
||||||
const entries = ref<DigestEntry[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
async function fetchAll() {
|
|
||||||
error.value = null
|
|
||||||
loading.value = true
|
|
||||||
const { data, error: err } = await useApiFetch<DigestEntry[]>('/api/digest-queue')
|
|
||||||
loading.value = false
|
|
||||||
if (err) {
|
|
||||||
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries.value = data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(id: number) {
|
|
||||||
const snapshot = entries.value.find(e => e.id === id)
|
|
||||||
entries.value = entries.value.filter(e => e.id !== id)
|
|
||||||
const { error: err } = await useApiFetch(`/api/digest-queue/${id}`, { method: 'DELETE' })
|
|
||||||
if (err) {
|
|
||||||
if (snapshot) entries.value = [...entries.value, snapshot]
|
|
||||||
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { entries, loading, error, fetchAll, remove }
|
|
||||||
})
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useDigestStore, type DigestEntry, type DigestLink } from '../stores/digest'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
|
|
||||||
const store = useDigestStore()
|
|
||||||
|
|
||||||
// Per-entry state keyed by DigestEntry.id
|
|
||||||
const expandedIds = ref<Record<number, boolean>>({})
|
|
||||||
const linkResults = ref<Record<number, DigestLink[]>>({})
|
|
||||||
const selectedUrls = ref<Record<number, Set<string>>>({})
|
|
||||||
const queueResult = ref<Record<number, { queued: number; skipped: number } | null>>({})
|
|
||||||
const extracting = ref<Record<number, boolean>>({})
|
|
||||||
const queuing = ref<Record<number, boolean>>({})
|
|
||||||
const entryError = ref<Record<number, string | null>>({})
|
|
||||||
|
|
||||||
onMounted(() => store.fetchAll())
|
|
||||||
|
|
||||||
function toggleExpand(id: number) {
|
|
||||||
expandedIds.value = { ...expandedIds.value, [id]: !expandedIds.value[id] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spread-copy pattern — same as expandedSignalIds in InterviewCard, safe for Vue 3 reactivity
|
|
||||||
function toggleUrl(entryId: number, url: string) {
|
|
||||||
const prev = selectedUrls.value[entryId] ?? new Set<string>()
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(url) ? next.delete(url) : next.add(url)
|
|
||||||
selectedUrls.value = { ...selectedUrls.value, [entryId]: next }
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedCount(id: number) {
|
|
||||||
return selectedUrls.value[id]?.size ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobLinks(id: number): DigestLink[] {
|
|
||||||
return (linkResults.value[id] ?? []).filter(l => l.score >= 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function otherLinks(id: number): DigestLink[] {
|
|
||||||
return (linkResults.value[id] ?? []).filter(l => l.score < 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractLinks(entry: DigestEntry) {
|
|
||||||
extracting.value = { ...extracting.value, [entry.id]: true }
|
|
||||||
const { data, error: err } = await useApiFetch<{ links: DigestLink[] }>(
|
|
||||||
`/api/digest-queue/${entry.id}/extract-links`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
)
|
|
||||||
extracting.value = { ...extracting.value, [entry.id]: false }
|
|
||||||
if (err) {
|
|
||||||
entryError.value = { ...entryError.value, [entry.id]: 'Could not extract links — try again' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entryError.value = { ...entryError.value, [entry.id]: null }
|
|
||||||
if (!data) return
|
|
||||||
linkResults.value = { ...linkResults.value, [entry.id]: data.links }
|
|
||||||
expandedIds.value = { ...expandedIds.value, [entry.id]: true }
|
|
||||||
// Pre-check job-likely links (score >= 2)
|
|
||||||
const preChecked = new Set(data.links.filter(l => l.score >= 2).map(l => l.url))
|
|
||||||
selectedUrls.value = { ...selectedUrls.value, [entry.id]: preChecked }
|
|
||||||
queueResult.value = { ...queueResult.value, [entry.id]: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueJobs(entry: DigestEntry) {
|
|
||||||
const urls = [...(selectedUrls.value[entry.id] ?? [])]
|
|
||||||
if (!urls.length) return
|
|
||||||
queuing.value = { ...queuing.value, [entry.id]: true }
|
|
||||||
const { data, error: err } = await useApiFetch<{ queued: number; skipped: number }>(
|
|
||||||
`/api/digest-queue/${entry.id}/queue-jobs`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ urls }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
queuing.value = { ...queuing.value, [entry.id]: false }
|
|
||||||
if (err) {
|
|
||||||
entryError.value = { ...entryError.value, [entry.id]: 'Could not queue jobs — try again' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entryError.value = { ...entryError.value, [entry.id]: null }
|
|
||||||
if (!data) return
|
|
||||||
queueResult.value = { ...queueResult.value, [entry.id]: data }
|
|
||||||
linkResults.value = { ...linkResults.value, [entry.id]: [] }
|
|
||||||
expandedIds.value = { ...expandedIds.value, [entry.id]: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
|
||||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="digest-view">
|
|
||||||
<h1 class="digest-heading">📰 Digest Queue</h1>
|
|
||||||
|
|
||||||
<div v-if="store.entries.length === 0" class="digest-empty">
|
|
||||||
<span class="empty-bird">🦅</span>
|
|
||||||
<p>No digest emails queued.</p>
|
|
||||||
<p class="empty-hint">When you mark an email as 📰 Digest, it appears here.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="digest-list">
|
|
||||||
<div v-for="entry in store.entries" :key="entry.id" class="digest-entry">
|
|
||||||
|
|
||||||
<!-- Entry header row -->
|
|
||||||
<div
|
|
||||||
class="entry-header"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
:aria-expanded="!!expandedIds[entry.id]"
|
|
||||||
:aria-label="`Toggle ${entry.subject}`"
|
|
||||||
@click="toggleExpand(entry.id)"
|
|
||||||
@keydown.enter.prevent="toggleExpand(entry.id)"
|
|
||||||
@keydown.space.prevent="toggleExpand(entry.id)"
|
|
||||||
>
|
|
||||||
<span class="entry-toggle" aria-hidden="true">{{ expandedIds[entry.id] ? '▾' : '▸' }}</span>
|
|
||||||
<div class="entry-meta">
|
|
||||||
<span class="entry-subject">{{ entry.subject }}</span>
|
|
||||||
<span class="entry-from">
|
|
||||||
<template v-if="entry.from_addr">From: {{ entry.from_addr }} · </template>
|
|
||||||
{{ formatDate(entry.received_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="entry-actions" @click.stop>
|
|
||||||
<button
|
|
||||||
class="btn-extract"
|
|
||||||
:disabled="extracting[entry.id]"
|
|
||||||
:aria-label="linkResults[entry.id]?.length ? 'Re-extract links' : 'Extract job links'"
|
|
||||||
@click="extractLinks(entry)"
|
|
||||||
>
|
|
||||||
{{ linkResults[entry.id]?.length ? 'Re-extract' : 'Extract' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-dismiss"
|
|
||||||
aria-label="Remove from digest queue"
|
|
||||||
@click="store.remove(entry.id)"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per-entry error -->
|
|
||||||
<div v-if="entryError[entry.id]" class="entry-error">{{ entryError[entry.id] }}</div>
|
|
||||||
|
|
||||||
<!-- Post-queue confirmation -->
|
|
||||||
<div v-if="queueResult[entry.id]" class="queue-result">
|
|
||||||
✅ {{ queueResult[entry.id]!.queued }}
|
|
||||||
job{{ queueResult[entry.id]!.queued !== 1 ? 's' : '' }} queued for review<template
|
|
||||||
v-if="queueResult[entry.id]!.skipped > 0"
|
|
||||||
>, {{ queueResult[entry.id]!.skipped }} skipped (already in pipeline)</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expanded: link list -->
|
|
||||||
<template v-if="expandedIds[entry.id]">
|
|
||||||
<div v-if="extracting[entry.id]" class="entry-status">Extracting links…</div>
|
|
||||||
|
|
||||||
<div v-else-if="linkResults[entry.id] !== undefined && !linkResults[entry.id]!.length" class="entry-status">
|
|
||||||
No job links found in this email.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="linkResults[entry.id]?.length" class="entry-links">
|
|
||||||
<!-- Job-likely links (score ≥ 2), pre-checked -->
|
|
||||||
<div class="link-group">
|
|
||||||
<label
|
|
||||||
v-for="link in jobLinks(entry.id)"
|
|
||||||
:key="link.url"
|
|
||||||
class="link-row"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="link-check"
|
|
||||||
:checked="selectedUrls[entry.id]?.has(link.url)"
|
|
||||||
@change="toggleUrl(entry.id, link.url)"
|
|
||||||
/>
|
|
||||||
<div class="link-text">
|
|
||||||
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
|
|
||||||
<span class="link-url">{{ link.url }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Other links (score = 1), unchecked -->
|
|
||||||
<template v-if="otherLinks(entry.id).length">
|
|
||||||
<div class="link-divider">Other links</div>
|
|
||||||
<div class="link-group">
|
|
||||||
<label
|
|
||||||
v-for="link in otherLinks(entry.id)"
|
|
||||||
:key="link.url"
|
|
||||||
class="link-row link-row--other"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="link-check"
|
|
||||||
:checked="selectedUrls[entry.id]?.has(link.url)"
|
|
||||||
@change="toggleUrl(entry.id, link.url)"
|
|
||||||
/>
|
|
||||||
<div class="link-text">
|
|
||||||
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
|
|
||||||
<span class="link-url">{{ link.url }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn-queue"
|
|
||||||
:disabled="selectedCount(entry.id) === 0 || queuing[entry.id]"
|
|
||||||
@click="queueJobs(entry)"
|
|
||||||
>
|
|
||||||
Queue {{ selectedCount(entry.id) > 0 ? selectedCount(entry.id) + ' ' : '' }}selected →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.digest-view {
|
|
||||||
padding: var(--space-6);
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-heading {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.digest-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-16) var(--space-8);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.empty-bird { font-size: 2.5rem; display: block; margin-bottom: var(--space-4); }
|
|
||||||
.empty-hint { font-size: 0.875rem; margin-top: var(--space-2); }
|
|
||||||
|
|
||||||
/* Entry list */
|
|
||||||
.digest-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
||||||
|
|
||||||
.digest-entry {
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entry header */
|
|
||||||
.entry-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.entry-header:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
.entry-toggle { color: var(--color-text-muted); font-size: 0.9rem; flex-shrink: 0; padding-top: 2px; }
|
|
||||||
|
|
||||||
.entry-meta { flex: 1; min-width: 0; }
|
|
||||||
.entry-subject {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.entry-from { display: block; font-size: 0.75rem; color: var(--color-text-muted); margin-top: 2px; }
|
|
||||||
|
|
||||||
.entry-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.btn-extract {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
.btn-extract:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
|
||||||
.btn-extract:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.btn-dismiss {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
.btn-dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
|
|
||||||
|
|
||||||
/* Queue result */
|
|
||||||
.queue-result {
|
|
||||||
margin: 0 var(--space-4) var(--space-3);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-success);
|
|
||||||
background: color-mix(in srgb, var(--color-success) 10%, var(--color-surface-raised));
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error message */
|
|
||||||
.entry-error {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-error);
|
|
||||||
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface-raised));
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 0 var(--space-4) var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status messages */
|
|
||||||
.entry-status {
|
|
||||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link list */
|
|
||||||
.entry-links { padding: 0 var(--space-4) var(--space-4); }
|
|
||||||
.link-group { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.link-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--color-surface);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.link-row:hover { background: var(--color-surface-alt); }
|
|
||||||
.link-row--other { opacity: 0.8; }
|
|
||||||
|
|
||||||
.link-check { flex-shrink: 0; margin-top: 3px; accent-color: var(--color-primary); cursor: pointer; }
|
|
||||||
|
|
||||||
.link-text { min-width: 0; flex: 1; }
|
|
||||||
.link-hint {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.link-url {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-divider {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: var(--space-3) 0 var(--space-2);
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-queue {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.btn-queue:hover:not(:disabled) { background: var(--color-primary-hover); }
|
|
||||||
.btn-queue:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.digest-view { padding: var(--space-4); }
|
|
||||||
.entry-subject { font-size: 0.85rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -108,14 +108,6 @@ async function reclassifyPreSignal(job: PipelineJob, sig: StageSignal, newLabel:
|
||||||
body: JSON.stringify({ stage_signal: newLabel }),
|
body: JSON.stringify({ stage_signal: newLabel }),
|
||||||
})
|
})
|
||||||
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
|
|
||||||
if (newLabel === 'digest') {
|
|
||||||
void useApiFetch('/api/digest-queue', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ job_contact_id: sig.id }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const prev = sig.stage_signal
|
const prev = sig.stage_signal
|
||||||
sig.stage_signal = newLabel
|
sig.stage_signal = newLabel
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue